From d60d334907874a3b25e4e6af7c3b1e7110afdbe7 Mon Sep 17 00:00:00 2001 From: juangaitanv Date: Wed, 27 May 2026 15:04:51 +0200 Subject: [PATCH 1/9] Add ./harness quality contract and route CI through it Zero-dep bash runner (check/fix/lint/test/audit/coverage/pre-commit/ci/ post-edit/setup-hooks/suppressions/install) wrapping cargo + git. ./harness ci is the single source of truth for the strict gate: clippy -D warnings, fmt check, cargo audit, and cargo-llvm-cov tests with a --fail-under-lines floor (13% baseline, will ratchet up as more code ships with tests). GitHub Actions test.yml runs the same gate so cloud CI matches local. AGENTS.md documents the commands. CLAUDE.md and .claude/ are gitignored so personal agent configs stay local. --- .github/workflows/test.yml | 13 +- .gitignore | 3 + AGENTS.md | 18 +++ harness | 290 +++++++++++++++++++++++++++++++++++++ 4 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 AGENTS.md create mode 100755 harness diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b1248a7..d63857b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,10 +17,17 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Install cargo-audit + uses: taiki-e/install-action@cargo-audit - name: Cache cargo uses: Swatinem/rust-cache@v2 - - - name: Run unit tests - run: cargo test + - name: CI gate + run: ./harness ci diff --git a/.gitignore b/.gitignore index 4afdf4d..593cff5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ *.zip node_modules/ /packages/*/vendor/ + +.claude/ +CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a98210f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# AGENTS.md + +This subproject is the Corgea developer CLI (Rust → npm + pip via maturin). + +## Commands + +- After edits: `./harness check` — clippy fix, format, tests, suppression report +- Pre-commit: `./harness pre-commit` — staged Rust files only (auto via git hook) +- CI: `./harness ci` — strict clippy (`-D warnings`), format check, dep audit, tests + coverage gate (min 13%) +- Audit: `./harness audit` — `cargo audit` for known dep vulnerabilities +- Coverage: `./harness coverage [--min=N]` — cargo-llvm-cov; HTML report under `target/llvm-cov/`; fails if line coverage < N (default 13) +- Lint: `./harness lint` — clippy + format check, no fixes +- Test: `./harness test` — `cargo test` +- Fix: `./harness fix` — clippy fix + format +- Setup: `./harness setup-hooks` — install `.git/hooks/pre-commit` +- Auto-format: `./harness post-edit` — runs `cargo fmt` on changed Rust files (wire into your editor/agent's post-edit hook) + +Add `--verbose` to stream raw command output instead of the quiet summary. diff --git a/harness b/harness new file mode 100755 index 0000000..e8085af --- /dev/null +++ b/harness @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +# Project development tasks. Bash + cargo + git only. +# Usage: ./harness [--verbose] [--min=N] +# +# Commands: check, fix, lint, test, audit, coverage, pre-commit, ci, +# post-edit, setup-hooks, suppressions, install + +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +VERBOSE=0 +COVERAGE_MIN=13 +for arg in "$@"; do + case "$arg" in + --verbose) VERBOSE=1 ;; + --min=*) COVERAGE_MIN="${arg#--min=}" ;; + esac +done + +if [ -t 1 ]; then + GREEN=$'\033[32m'; RED=$'\033[31m'; BLUE=$'\033[34m'; DIM=$'\033[2m'; RESET=$'\033[0m' +else + GREEN=""; RED=""; BLUE=""; DIM=""; RESET="" +fi + +# ── Runner ────────────────────────────────────────────────────────── +# run -- +# Quiet by default: captures stdout+stderr, prints only on failure. +# --verbose streams raw output. +# no_exit=1 lets the caller aggregate failures (e.g. cmd_check). + +LAST_RC=0 +LAST_OUTPUT="" + +run() { + local desc="$1"; shift + local no_exit="$1"; shift + [ "$1" = "--" ] && shift + + if [ "$VERBOSE" -eq 1 ]; then + printf " %s→ %s%s\n" "$DIM" "$*" "$RESET" + "$@" + LAST_RC=$? + LAST_OUTPUT="" + if [ "$LAST_RC" -eq 0 ]; then + printf " %s✓%s %s\n" "$GREEN" "$RESET" "$desc" + return 0 + fi + printf " %s✗%s %s\n" "$RED" "$RESET" "$desc" + [ "$no_exit" = "0" ] && exit "$LAST_RC" + return "$LAST_RC" + fi + + local tmp; tmp="$(mktemp)" + "$@" >"$tmp" 2>&1 + LAST_RC=$? + LAST_OUTPUT="$(cat "$tmp")" + rm -f "$tmp" + if [ "$LAST_RC" -eq 0 ]; then + printf " %s✓%s %s\n" "$GREEN" "$RESET" "$desc" + return 0 + fi + printf " %s✗%s %s\n" "$RED" "$RESET" "$desc" + [ -n "$LAST_OUTPUT" ] && printf "%s\n" "$LAST_OUTPUT" + [ "$no_exit" = "0" ] && exit "$LAST_RC" + return "$LAST_RC" +} + +run_with_summary() { + local desc="$1"; shift + local no_exit="$1"; shift + [ "$1" = "--" ] && shift + + run "$desc" "$no_exit" -- "$@" + local rc=$? + [ $rc -ne 0 ] && return $rc + + # Reprint last line with test summary suffix (cargo test). + local passed total_passed=0 duration=0 + while IFS= read -r line; do + passed="$(printf "%s" "$line" | sed -nE 's/.*ok\. ([0-9]+) passed.*/\1/p')" + [ -n "$passed" ] && total_passed=$(( total_passed + passed )) + local d + d="$(printf "%s" "$line" | sed -nE 's/.*finished in ([0-9.]+)s.*/\1/p')" + if [ -n "$d" ]; then + awk_cmp=$(awk -v a="$d" -v b="$duration" 'BEGIN{print (a>b)?1:0}') + [ "$awk_cmp" = "1" ] && duration="$d" + fi + done <<<"$LAST_OUTPUT" + if [ "$total_passed" -gt 0 ]; then + # Overwrite previous OK line with summary detail. + printf "\033[1A\033[2K %s✓%s %s %s(%s passed, %ss)%s\n" \ + "$GREEN" "$RESET" "$desc" "$DIM" "$total_passed" "$duration" "$RESET" + fi + return 0 +} + +# ── Git helpers ───────────────────────────────────────────────────── + +staged_rs_files() { + git diff --cached --name-only --diff-filter=d --relative 2>/dev/null \ + | grep -E '\.rs$' || true +} + +changed_rs_files() { + git status --porcelain 2>/dev/null \ + | sed -E 's/^...//' \ + | grep -E '\.rs$' || true +} + +# ── Suppressions (report-only) ────────────────────────────────────── + +cmd_suppressions() { + printf "\n=== Suppressions ===\n\n" + local total=0 line_total=0 crate_total=0 + local file + local tmp; tmp="$(mktemp)" + while IFS= read -r -d '' file; do + grep -oE '#!?\[allow\([^)]*\)\]' "$file" 2>/dev/null >>"$tmp" || true + done < <(find src -type f -name '*.rs' -print0 2>/dev/null) + [ -d tests ] && while IFS= read -r -d '' file; do + grep -oE '#!?\[allow\([^)]*\)\]' "$file" 2>/dev/null >>"$tmp" || true + done < <(find tests -type f -name '*.rs' -print0 2>/dev/null) + + line_total=$(awk '/^#\[allow/ {n++} END{print n+0}' "$tmp") + crate_total=$(awk '/^#!\[allow/ {n++} END{print n+0}' "$tmp") + total=$(( line_total + crate_total )) + + printf "Suppressions: %d total\n" "$total" + [ "$total" -eq 0 ] && { rm -f "$tmp"; return 0; } + [ "$line_total" -gt 0 ] && printf " allow: %d\n" "$line_total" + [ "$crate_total" -gt 0 ] && printf " allow_crate: %d\n" "$crate_total" + + # Top 10 rules across both kinds. + sed -E 's/#!?\[allow\(([^)]*)\)\]/\1/' "$tmp" \ + | tr ',' '\n' \ + | sed -E 's/^[[:space:]]+|[[:space:]]+$//g' \ + | grep -v '^$' \ + | sort | uniq -c | sort -rn | head -10 \ + | awk '{ rule=$2; for (i=3;i<=NF;i++) rule=rule" "$i; printf " %s: %d\n", rule, $1 }' + rm -f "$tmp" + return 0 +} + +# ── Commands ──────────────────────────────────────────────────────── + +cmd_fix() { + run "Clippy fix" 0 -- cargo clippy --fix --allow-dirty --allow-staged + run "Format" 0 -- cargo fmt +} + +cmd_lint() { + run "Clippy" 0 -- cargo clippy + run "Format check" 0 -- cargo fmt --check +} + +cmd_test() { + run_with_summary "Tests" 0 -- cargo test +} + +cmd_audit() { + _cmd_audit_inner 0 +} + +_cmd_audit_inner() { + local strict="$1" + if cargo audit --version >/dev/null 2>&1; then + run "Dep audit" 0 -- cargo audit + return + fi + if [ "$strict" = "1" ]; then + printf " %s✗%s Dep audit (cargo-audit not installed)\n" "$RED" "$RESET" + exit 1 + fi + printf " %s⊘ Dep audit skipped (install: cargo install cargo-audit)%s\n" "$DIM" "$RESET" +} + +cmd_coverage() { + printf "\n%s[coverage]%s min=%s%%\n\n" "$BLUE" "$RESET" "$COVERAGE_MIN" + if ! cargo llvm-cov --version >/dev/null 2>&1; then + printf " %s✗%s Coverage (cargo-llvm-cov not installed)\n" "$RED" "$RESET" + printf " %sInstall:%s cargo install cargo-llvm-cov\n" "$DIM" "$RESET" + exit 1 + fi + run "Coverage (min ${COVERAGE_MIN}%)" 0 -- \ + cargo llvm-cov --summary-only --fail-under-lines "$COVERAGE_MIN" + run "HTML report" 0 -- cargo llvm-cov report --html + printf " %sHTML:%s %s/target/llvm-cov/html/index.html\n" \ + "$DIM" "$RESET" "$ROOT" +} + +cmd_post_edit() { + local changed; changed="$(changed_rs_files)" + [ -z "$changed" ] && return 0 + # Never fail the Stop hook. + run "Format" 1 -- cargo fmt || true + return 0 +} + +cmd_pre_commit() { + local staged; staged="$(staged_rs_files)" + if [ -z "$staged" ]; then + printf "No staged Rust files — skipping checks\n" + return 0 + fi + printf "\n%s[pre-commit]%s\n\n" "$BLUE" "$RESET" + cmd_fix + cmd_test +} + +cmd_check() { + local start; start=$(date +%s) + printf "\n%s[check]%s Running pre-flight checks...\n\n" "$BLUE" "$RESET" + + local passed=0 failed=0 + run "Clippy fix" 1 -- cargo clippy --fix --allow-dirty --allow-staged + [ $? -eq 0 ] && passed=$(( passed + 1 )) || failed=$(( failed + 1 )) + run "Format" 1 -- cargo fmt + [ $? -eq 0 ] && passed=$(( passed + 1 )) || failed=$(( failed + 1 )) + run "Clippy (strict)" 1 -- cargo clippy -- -D warnings + [ $? -eq 0 ] && passed=$(( passed + 1 )) || failed=$(( failed + 1 )) + run_with_summary "Tests" 1 -- cargo test + [ $? -eq 0 ] && passed=$(( passed + 1 )) || failed=$(( failed + 1 )) + + cmd_suppressions + + local elapsed=$(( $(date +%s) - start )) + printf "\n" + if [ "$failed" -gt 0 ]; then + printf "%sFAIL%s %d passed, %d failed %s(%ds)%s\n" \ + "$RED" "$RESET" "$passed" "$failed" "$DIM" "$elapsed" "$RESET" + exit 1 + fi + printf "%sOK%s %d passed %s(%ds)%s\n" \ + "$GREEN" "$RESET" "$passed" "$DIM" "$elapsed" "$RESET" +} + +cmd_ci() { + printf "\n%s[ci]%s\n\n" "$BLUE" "$RESET" + run "Clippy (strict)" 0 -- cargo clippy -- -D warnings + run "Format check" 0 -- cargo fmt --check + _cmd_audit_inner 1 + if ! cargo llvm-cov --version >/dev/null 2>&1; then + printf " %s✗%s Coverage (cargo-llvm-cov not installed)\n" "$RED" "$RESET" + printf " %sInstall:%s cargo install cargo-llvm-cov\n" "$DIM" "$RESET" + exit 1 + fi + run_with_summary "Tests + coverage (min ${COVERAGE_MIN}%)" 0 -- \ + cargo llvm-cov --summary-only --fail-under-lines "$COVERAGE_MIN" +} + +cmd_setup_hooks() { + local hook_dir="$ROOT/.git/hooks" + local hook="$hook_dir/pre-commit" + mkdir -p "$hook_dir" + cat >"$hook" <<'EOF' +#!/bin/sh +exec "$(git rev-parse --show-toplevel)/harness" pre-commit +EOF + chmod +x "$hook" + printf "Installed pre-commit hook at %s\n" "$hook" +} + +# ── Dispatch ──────────────────────────────────────────────────────── + +cmd="${1:-check}" +case "$cmd" in + check) cmd_check ;; + fix) cmd_fix ;; + lint) cmd_lint ;; + test) cmd_test ;; + audit) cmd_audit ;; + coverage) cmd_coverage ;; + pre-commit) cmd_pre_commit ;; + ci) cmd_ci ;; + post-edit) cmd_post_edit ;; + setup-hooks) cmd_setup_hooks ;; + suppressions) cmd_suppressions ;; + -h|--help|help) + printf "Usage: ./harness [--verbose] [--min=N]\n\n" + printf "Commands: check, fix, lint, test, audit, coverage, pre-commit,\n" + printf " ci, post-edit, setup-hooks, suppressions, install\n" + ;; + *) + printf "Unknown command: %s\n" "$cmd" >&2 + exit 1 + ;; +esac From 915dce240b1a9f9552ea278f15fa0364c67714a1 Mon Sep 17 00:00:00 2001 From: juangaitanv Date: Wed, 27 May 2026 15:04:57 +0200 Subject: [PATCH 2/9] Apply cargo fmt and clippy fixes; cover utils/api.rs auth helpers Reformats per rustfmt and applies clippy lints (needless_return, collapsible_if, eq_ignore_ascii_case, double_ended_iterator_last, print_with_newline, contains_key, needless_late_init, useless_format/vec, borrowed_box, etc.) so ./harness ci's strict clippy gate passes. Adds unit tests for utils::api::{is_jwt, auth_headers, check_for_warnings} so the coverage gate isn't sitting on 0%. Pure refactor + tests: no behavior change. --- src/authorize.rs | 110 ++++--- src/cicd.rs | 7 +- src/config.rs | 12 +- src/inspect.rs | 82 +++-- src/list.rs | 176 ++++++---- src/log.rs | 4 +- src/main.rs | 198 +++++++++--- src/scan.rs | 175 ++++++---- src/scanners/blast.rs | 323 ++++++++++-------- src/scanners/fortify.rs | 25 +- src/scanners/parsers/checkmarx.rs | 39 ++- src/scanners/parsers/coverity.rs | 18 +- src/scanners/parsers/mod.rs | 13 +- src/scanners/parsers/sarif.rs | 38 ++- src/scanners/parsers/semgrep.rs | 14 +- src/setup_hooks.rs | 33 +- src/targets.rs | 167 +++++----- src/utils/api.rs | 522 +++++++++++++++++++----------- src/utils/generic.rs | 71 ++-- src/utils/terminal.rs | 100 ++++-- src/wait.rs | 40 ++- 21 files changed, 1337 insertions(+), 830 deletions(-) diff --git a/src/authorize.rs b/src/authorize.rs index 39b5df3..686c042 100644 --- a/src/authorize.rs +++ b/src/authorize.rs @@ -1,17 +1,19 @@ -use crate::{config::Config, utils::{terminal, api}}; +use crate::{ + config::Config, + utils::{api, terminal}, +}; +use http_body_util::Full; +use hyper::body::Bytes; use hyper::body::Incoming; use hyper::service::service_fn; use hyper::{Request, Response, StatusCode}; use hyper_util::rt::TokioIo; -use http_body_util::Full; -use hyper::body::Bytes; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; use tokio::net::TcpListener; - const DEFAULT_PORT: u16 = 9876; pub fn run(scope: Option, url: Option) -> Result<(), Box> { @@ -24,60 +26,62 @@ pub fn run(scope: Option, url: Option) -> Result<(), Box "https://www.corgea.app".to_string(), }; - + // Find available port starting from default let port = find_available_port(DEFAULT_PORT)?; let callback_url = format!("http://localhost:{}", port); - let auth_url = format!("{}/authorize?callback={}", base_domain, - urlencoding::encode(&callback_url)); - + let auth_url = format!( + "{}/authorize?callback={}", + base_domain, + urlencoding::encode(&callback_url) + ); + println!("Opening browser to authorize Corgea CLI..."); println!("Authorization URL: {}", auth_url); - + // Open browser if let Err(e) = open::that(&auth_url) { eprintln!("Failed to open browser automatically: {}", e); println!("Please manually open the following URL in your browser:"); println!("{}", auth_url); } - + // Set up shared state for the authorization code let auth_code = Arc::new(Mutex::new(None::)); let auth_code_clone = auth_code.clone(); - + // Set up loading message let stop_signal = Arc::new(Mutex::new(false)); let stop_signal_clone = stop_signal.clone(); - + // Start loading spinner in a separate thread let loading_handle = thread::spawn(move || { terminal::show_loading_message("Waiting for authorization...", stop_signal_clone); }); - + // Start the HTTP server to listen for the callback let rt = tokio::runtime::Runtime::new()?; - let result = rt.block_on(async { - start_callback_server(port, auth_code_clone).await - }); - + let result = rt.block_on(async { start_callback_server(port, auth_code_clone).await }); + // Stop the loading spinner *stop_signal.lock().unwrap() = true; loading_handle.join().unwrap(); - + match result { Ok(code) => { - // Exchange the code for a user token let user_token = api::exchange_code_for_token(&base_domain, &code)?; - + // Save the user token to config let mut config = Config::load().expect("Failed to load config"); - config.set_token(user_token).expect("Failed to save user token"); + config + .set_token(user_token) + .expect("Failed to save user token"); config.set_url(base_domain).expect("Failed to save URL"); - + println!("\r🎉 Successfully authenticated to Corgea!"); println!("You can now use other Corgea CLI commands."); - + Ok(()) } Err(e) => { @@ -95,7 +99,7 @@ fn find_available_port(start_port: u16) -> Result Result Result> { let addr = format!("127.0.0.1:{}", port); let listener = match TcpListener::bind(&addr).await { - Ok(listener) => { - listener - } + Ok(listener) => listener, Err(e) => { return Err(format!("Failed to bind to {}: {}", addr, e).into()); } }; - + loop { tokio::select! { accept_result = listener.accept() => { @@ -175,17 +177,17 @@ async fn handle_callback( auth_code: Arc>>, ) -> Result>, hyper::Error> { let uri = req.uri(); - + // Parse query parameters if let Some(query) = uri.query() { let params = parse_query_params(query); - + if let Some(code) = params.get("code") { // Store the authorization code if let Ok(mut code_guard) = auth_code.lock() { *code_guard = Some(code.clone()); } - + // Return success page let success_html = r#" @@ -357,20 +359,20 @@ async fn handle_callback( "#; - + return Ok(Response::builder() .status(StatusCode::OK) .header("Content-Type", "text/html") .body(Full::new(Bytes::from(success_html))) .unwrap()); } - + if let Some(error) = params.get("error") { let default_error = "Unknown error occurred".to_string(); - let error_description = params.get("error_description") - .unwrap_or(&default_error); - - let error_html = format!(r#" + let error_description = params.get("error_description").unwrap_or(&default_error); + + let error_html = format!( + r#" @@ -432,8 +434,10 @@ async fn handle_callback( - "#, error, error_description); - + "#, + error, error_description + ); + return Ok(Response::builder() .status(StatusCode::BAD_REQUEST) .header("Content-Type", "text/html") @@ -441,7 +445,7 @@ async fn handle_callback( .unwrap()); } } - + // Default response for other requests let response_html = r#" @@ -500,7 +504,7 @@ async fn handle_callback( "#; - + Ok(Response::builder() .status(StatusCode::OK) .header("Content-Type", "text/html") @@ -514,20 +518,16 @@ fn parse_query_params(query: &str) -> HashMap { .filter_map(|param| { let mut parts = param.splitn(2, '='); match (parts.next(), parts.next()) { - (Some(key), Some(value)) => { - Some(( - urlencoding::decode(key).ok()?.into_owned(), - urlencoding::decode(value).ok()?.into_owned(), - )) - } + (Some(key), Some(value)) => Some(( + urlencoding::decode(key).ok()?.into_owned(), + urlencoding::decode(value).ok()?.into_owned(), + )), _ => None, } }) .collect() } - - #[cfg(test)] mod tests { use super::*; @@ -541,7 +541,10 @@ mod tests { fn reserve_ephemeral_port() -> u16 { let listener = StdTcpListener::bind("127.0.0.1:0").expect("failed to bind ephemeral port"); - listener.local_addr().expect("failed to get local addr").port() + listener + .local_addr() + .expect("failed to get local addr") + .port() } fn spawn_callback_server( @@ -604,7 +607,10 @@ mod tests { let params = parse_query_params("code=a%20b&error_description=needs%2Blogin"); assert_eq!(params.get("code"), Some(&"a b".to_string())); - assert_eq!(params.get("error_description"), Some(&"needs+login".to_string())); + assert_eq!( + params.get("error_description"), + Some(&"needs+login".to_string()) + ); } #[test] diff --git a/src/cicd.rs b/src/cicd.rs index 7743784..40e075e 100644 --- a/src/cicd.rs +++ b/src/cicd.rs @@ -1,20 +1,19 @@ - pub fn running_in_ci() -> bool { // this will need to be updated to include other CI systems std::env::var("CI").is_ok() && std::env::var("GITHUB_ACTIONS").is_ok() } pub fn which_ci() -> String { - return if std::env::var("GITHUB_ACTIONS").is_ok() { + if std::env::var("GITHUB_ACTIONS").is_ok() { "github".to_string() } else { "unknown".to_string() } } - pub fn get_github_env_vars() -> std::collections::HashMap { - let mut github_env_vars: std::collections::HashMap = std::collections::HashMap::new(); + let mut github_env_vars: std::collections::HashMap = + std::collections::HashMap::new(); for (key, value) in std::env::vars() { if key.starts_with("GITHUB_") { diff --git a/src/config.rs b/src/config.rs index 8976c61..257a483 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,6 @@ -use dirs; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::{env, fs, io}; -use toml; #[derive(Serialize, Deserialize, Clone)] pub struct Config { @@ -13,10 +11,8 @@ pub struct Config { impl Config { fn config_path() -> io::Result { - let mut dir_path = dirs::home_dir().ok_or(io::Error::new( - io::ErrorKind::Other, - "Unable to get home directory", - ))?; + let mut dir_path = + dirs::home_dir().ok_or(io::Error::other("Unable to get home directory"))?; dir_path.push(".corgea"); @@ -95,13 +91,13 @@ impl Config { return corgea_token; } - return self.token.clone(); + self.token.clone() } pub fn get_debug(&self) -> i8 { if let Ok(corgea_debug) = env::var("CORGEA_DEBUG") { return corgea_debug.parse::().unwrap_or(0); } - return self.debug; + self.debug } } diff --git a/src/inspect.rs b/src/inspect.rs index 0933d0c..89fe21e 100644 --- a/src/inspect.rs +++ b/src/inspect.rs @@ -1,16 +1,15 @@ -use crate::utils; use crate::config::Config; -use std::time::SystemTime; use crate::scanners; +use crate::utils; +use std::time::SystemTime; pub fn run( - config: &Config, - issues: &bool, - json: &bool, - summary: &bool, - fix_explanation: &bool, - fix_diff: &bool, + config: &Config, + issues: &bool, + json: &bool, + summary: &bool, + fix_explanation: &bool, + fix_diff: &bool, id: &String, - ) { fn print_section(title: &str, value: impl ToString) { println!("{:<15}: {}", title, value.to_string()); @@ -22,7 +21,10 @@ pub fn run( let issue_details = match utils::api::get_issue(&config.get_url(), id) { Ok(issue) => issue, Err(e) => { - eprintln!("Failed to fetch issue details for issue ID {} with error:\n{}", id, e); + eprintln!( + "Failed to fetch issue details for issue ID {} with error:\n{}", + id, e + ); if e.to_string().contains("404") { println!("If you're trying to inspect a scan make sure to remove the --issue argument"); } @@ -38,33 +40,45 @@ pub fn run( print_section("Urgency", &issue_details.issue.urgency); print_section("Category", &issue_details.issue.classification.name); print_section("File Path", &issue_details.issue.location.file.path); - print_section("Line Num", issue_details.issue.location.line_number.to_string()); - print_section("Status", utils::generic::get_status(&issue_details.issue.status)); + print_section( + "Line Num", + issue_details.issue.location.line_number.to_string(), + ); + print_section( + "Status", + utils::generic::get_status(&issue_details.issue.status), + ); } if let Some(ref details) = issue_details.issue.details { if let Some(ref explanation) = details.explanation { if *summary || show_everything { - println!("Explanation:\n\n{}\n-------------------------", utils::terminal::format_code(explanation)) + println!( + "Explanation:\n\n{}\n-------------------------", + utils::terminal::format_code(explanation) + ) } } - } + } if let Some(auto_fix_suggestion) = issue_details.issue.auto_fix_suggestion { if *fix_explanation || show_everything { if show_everything { - utils::terminal::prompt_to_continue_or_exit(Some("\nTo continue to viewing the fix explanation please press enter, otherwise Ctrl+C to exit.\n".into())); + utils::terminal::prompt_to_continue_or_exit(Some("\nTo continue to viewing the fix explanation please press enter, otherwise Ctrl+C to exit.\n")); } if let Some(ref patch) = &auto_fix_suggestion.patch { utils::terminal::print_with_pagination(&format!( - "Fix Explanation:\n\n{}\n-------------------------", utils::terminal::format_code(&patch.explanation) + "Fix Explanation:\n\n{}\n-------------------------", + utils::terminal::format_code(&patch.explanation) )); } } - if *fix_diff || show_everything { + if *fix_diff || show_everything { if show_everything { - utils::terminal::prompt_to_continue_or_exit(Some("\nTo continue to viewing the diff of the fix please press enter, otherwise Ctrl+C to exit.\n".into())); + utils::terminal::prompt_to_continue_or_exit(Some("\nTo continue to viewing the diff of the fix please press enter, otherwise Ctrl+C to exit.\n")); } if let Some(ref patch) = &auto_fix_suggestion.patch { - utils::terminal::print_with_pagination(&utils::terminal::format_diff(&patch.diff)); + utils::terminal::print_with_pagination(&utils::terminal::format_diff( + &patch.diff, + )); } } } @@ -74,7 +88,9 @@ pub fn run( Err(e) => { eprintln!("Failed to fetch scan details for scan ID {}: {}", id, e); if e.to_string().contains("404") { - println!("If you're trying to inspect an issues make sure to pass --issue argument"); + println!( + "If you're trying to inspect an issues make sure to pass --issue argument" + ); } std::process::exit(1); } @@ -90,21 +106,21 @@ pub fn run( print_section("Status", scan_details.status); print_section("Project", &scan_details.project); print_section("Engine", &scan_details.engine); - let created_at = chrono::DateTime::::from(SystemTime::now()).format("%Y-%m-%d %H:%M:%S").to_string(); + let created_at = chrono::DateTime::::from(SystemTime::now()) + .format("%Y-%m-%d %H:%M:%S") + .to_string(); print_section("Created At", &created_at); - match scanners::blast::fetch_and_group_scan_issues(&config.get_url(), &scan_details.project) { - Ok(counts) => { - let total_issues = counts.values().sum::(); - let order = vec!["CR", "HI", "ME", "LO"]; - for urgency in order { - if let Some(count) = counts.get(urgency) { - print_section(&format!("{} Issues", urgency), &count.to_string()); - } + if let Ok(counts) = + scanners::blast::fetch_and_group_scan_issues(&config.get_url(), &scan_details.project) + { + let total_issues = counts.values().sum::(); + let order = vec!["CR", "HI", "ME", "LO"]; + for urgency in order { + if let Some(count) = counts.get(urgency) { + print_section(&format!("{} Issues", urgency), count.to_string()); } - print_section("Total Issues", &total_issues); - }, - Err(_) => { } + } + print_section("Total Issues", total_issues); }; - } } diff --git a/src/list.rs b/src/list.rs index afacc31..571559b 100644 --- a/src/list.rs +++ b/src/list.rs @@ -1,17 +1,31 @@ -use crate::utils; use crate::config::Config; -use std::path::Path; -use serde_json::json; use crate::log::debug; +use crate::utils; +use serde_json::json; +use std::path::Path; -pub fn run(config: &Config, issues: &bool, sca_issues: &bool, json: &bool, page: &Option, page_size: &Option, scan_id: &Option) { - let project_name = utils::generic::get_current_working_directory().unwrap_or("unknown".to_string()); - println!(""); +pub fn run( + config: &Config, + issues: &bool, + sca_issues: &bool, + json: &bool, + page: &Option, + page_size: &Option, + scan_id: &Option, +) { + let project_name = + utils::generic::get_current_working_directory().unwrap_or("unknown".to_string()); + println!(); if *sca_issues { - let sca_issues_response = match utils::api::get_sca_issues(&config.get_url(), Some((*page).unwrap_or(1)), *page_size, scan_id.clone()) { + let sca_issues_response = match utils::api::get_sca_issues( + &config.get_url(), + Some((*page).unwrap_or(1)), + *page_size, + scan_id.clone(), + ) { Ok(response) => response, Err(e) => { - debug(&format!("Error Sending Request: {}", e.to_string())); + debug(&format!("Error Sending Request: {}", e)); if e.to_string().contains("404") { if scan_id.is_some() { eprintln!("Scan with ID '{}' doesn't exist or has no SCA issues. Please run 'corgea scan' to create a new scan for this project.", scan_id.as_ref().unwrap()); @@ -42,18 +56,16 @@ pub fn run(config: &Config, issues: &bool, sca_issues: &bool, json: &bool, page: return; } - let mut table = vec![ - vec![ - "Issue ID".to_string(), - "Package".to_string(), - "Version".to_string(), - "Fix Version".to_string(), - "Severity".to_string(), - "CVE".to_string(), - "Ecosystem".to_string(), - "File Path".to_string(), - ], - ]; + let mut table = vec![vec![ + "Issue ID".to_string(), + "Package".to_string(), + "Version".to_string(), + "Fix Version".to_string(), + "Severity".to_string(), + "CVE".to_string(), + "Ecosystem".to_string(), + "File Path".to_string(), + ]]; for issue in &sca_issues_response.issues { let path = Path::new(&issue.location.path); @@ -77,7 +89,11 @@ pub fn run(config: &Config, issues: &bool, sca_issues: &bool, json: &bool, page: issue.id.clone(), issue.package.name.clone(), issue.package.version.clone(), - issue.package.fix_version.clone().unwrap_or("N/A".to_string()), + issue + .package + .fix_version + .clone() + .unwrap_or("N/A".to_string()), issue.severity.clone().unwrap_or("N/A".to_string()), issue.cve.clone().unwrap_or("N/A".to_string()), issue.package.ecosystem.clone(), @@ -85,12 +101,22 @@ pub fn run(config: &Config, issues: &bool, sca_issues: &bool, json: &bool, page: ]); } - utils::terminal::print_table(table, Some(sca_issues_response.page), Some(sca_issues_response.total_pages)); + utils::terminal::print_table( + table, + Some(sca_issues_response.page), + Some(sca_issues_response.total_pages), + ); } else if *issues { - let issues_response = match utils::api::get_scan_issues(&config.get_url(), &project_name, Some((*page).unwrap_or(1)), *page_size, scan_id.clone()) { + let issues_response = match utils::api::get_scan_issues( + &config.get_url(), + &project_name, + Some((*page).unwrap_or(1)), + *page_size, + scan_id.clone(), + ) { Ok(response) => response, Err(e) => { - debug(&format!("Error Sending Request: {}", e.to_string())); + debug(&format!("Error Sending Request: {}", e)); if e.to_string().contains("404") { if scan_id.is_some() { eprintln!("Scan with ID '{}' doesn't exist. Please run 'corgea scan' to create a new scan for this project.", scan_id.as_ref().unwrap()); @@ -110,12 +136,17 @@ pub fn run(config: &Config, issues: &bool, sca_issues: &bool, json: &bool, page: } }; let mut render_blocking_rules = false; - let mut blocking_rules: std::collections::HashMap = std::collections::HashMap::new(); + let mut blocking_rules: std::collections::HashMap = + std::collections::HashMap::new(); if scan_id.is_some() { let mut page: u32 = 1; loop { - match utils::api::check_blocking_rules(&config.get_url(), scan_id.as_ref().unwrap(), Some(page)) { + match utils::api::check_blocking_rules( + &config.get_url(), + scan_id.as_ref().unwrap(), + Some(page), + ) { Ok(rules) => { if rules.block { render_blocking_rules = true; @@ -138,7 +169,6 @@ pub fn run(config: &Config, issues: &bool, sca_issues: &bool, json: &bool, page: } } - if *json { let mut json = serde_json::json!({ "page": issues_response.page, @@ -146,30 +176,31 @@ pub fn run(config: &Config, issues: &bool, sca_issues: &bool, json: &bool, page: "results": &issues_response.issues }); if render_blocking_rules { - json["results"] = serde_json::json!( - issues_response.issues.unwrap_or_default().iter().map(|issue| { - serde_json::json!( - utils::api::IssueWithBlockingRules { - id: issue.id.clone(), - scan_id: issue.scan_id.clone(), - status: issue.status.clone(), - urgency: issue.urgency.clone(), - created_at: issue.created_at.clone(), - classification: issue.classification.clone(), - location: issue.location.clone(), - details: issue.details.clone(), - auto_triage: issue.auto_triage.clone(), - auto_fix_suggestion: issue.auto_fix_suggestion.clone(), - blocked: blocking_rules.contains_key(&issue.id), - blocking_rules: if blocking_rules.contains_key(&issue.id) { - Some(vec![blocking_rules.get(&issue.id).unwrap().clone()]) - } else { - None - } + json["results"] = serde_json::json!(issues_response + .issues + .unwrap_or_default() + .iter() + .map(|issue| { + serde_json::json!(utils::api::IssueWithBlockingRules { + id: issue.id.clone(), + scan_id: issue.scan_id.clone(), + status: issue.status.clone(), + urgency: issue.urgency.clone(), + created_at: issue.created_at.clone(), + classification: issue.classification.clone(), + location: issue.location.clone(), + details: issue.details.clone(), + auto_triage: issue.auto_triage.clone(), + auto_fix_suggestion: issue.auto_fix_suggestion.clone(), + blocked: blocking_rules.contains_key(&issue.id), + blocking_rules: if blocking_rules.contains_key(&issue.id) { + Some(vec![blocking_rules.get(&issue.id).unwrap().clone()]) + } else { + None } - ) - }).collect::>() - ); + }) + }) + .collect::>()); } let output = json!(json); println!("{}", serde_json::to_string_pretty(&output).unwrap()); @@ -186,9 +217,7 @@ pub fn run(config: &Config, issues: &bool, sca_issues: &bool, json: &bool, page: table_header.push("Blocking".to_string()); table_header.push("Rule ID".to_string()); } - let mut table = vec![ - table_header - ]; + let mut table = vec![table_header]; for issue in &issues_response.issues.unwrap_or_default() { let classification_display = issue.classification.id.clone(); @@ -216,23 +245,36 @@ pub fn run(config: &Config, issues: &bool, sca_issues: &bool, json: &bool, page: issue.location.line_number.to_string(), ]; if render_blocking_rules { - row.push(blocking_rules.get(&issue.id).is_some().to_string()); - row.push(blocking_rules.get(&issue.id).unwrap_or(&"".to_string()).to_string()); + row.push(blocking_rules.contains_key(&issue.id).to_string()); + row.push( + blocking_rules + .get(&issue.id) + .unwrap_or(&"".to_string()) + .to_string(), + ); } table.push(row); } utils::terminal::print_table(table, issues_response.page, issues_response.total_pages); } else { - let (scans, page, total_pages) = match utils::api::query_scan_list(&config.get_url(), Some(&project_name), *page, *page_size) { + let (scans, page, total_pages) = match utils::api::query_scan_list( + &config.get_url(), + Some(&project_name), + *page, + *page_size, + ) { Ok(scans) => { let page = scans.page; let total_pages = scans.total_pages; - let filtered_scans: Vec = scans.scans.unwrap_or_default().into_iter() + let filtered_scans: Vec = scans + .scans + .unwrap_or_default() + .into_iter() .filter(|scan| scan.project == project_name) .collect(); (filtered_scans, page, total_pages) - }, + } Err(e) => { if e.to_string().contains("404") { eprintln!("Project with name '{}' doesn't exist. Please run 'corgea scan' to create a new scan for this project.", project_name); @@ -256,20 +298,18 @@ pub fn run(config: &Config, issues: &bool, sca_issues: &bool, json: &bool, page: println!("{}", serde_json::to_string_pretty(&output).unwrap()); return; } - let mut table = vec![ - vec![ - "Scan ID".to_string(), - "Project".to_string(), - "Status".to_string(), - "Repo".to_string(), - "Branch".to_string(), - ], - ]; + let mut table = vec![vec![ + "Scan ID".to_string(), + "Project".to_string(), + "Status".to_string(), + "Repo".to_string(), + "Branch".to_string(), + ]]; for scan in &scans { let formatted_repo = scan.repo.clone().unwrap_or("N/A".to_string()); let formatted_repo = if formatted_repo != "N/A" { - if let Some(repo_name) = formatted_repo.split('/').last() { + if let Some(repo_name) = formatted_repo.split('/').next_back() { let owner = formatted_repo.split('/').nth(3).unwrap_or("unknown"); let repo_name = repo_name.strip_suffix(".git").unwrap_or(repo_name); format!("{}/{}", owner, repo_name) diff --git a/src/log.rs b/src/log.rs index daf745a..7f193fe 100644 --- a/src/log.rs +++ b/src/log.rs @@ -1,8 +1,8 @@ -use crate::config::Config; +use crate::config::Config; pub fn debug(input: &str) { let config = Config::load().expect("Failed to load config"); if config.get_debug() == 1 { println!("DEBUG: {}\n", input); } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 5da00f9..0802e1e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,28 +1,28 @@ +mod authorize; +mod cicd; mod config; -mod scan; -mod wait; -mod list; mod inspect; -mod cicd; +mod list; mod log; +mod scan; mod setup_hooks; -mod authorize; +mod wait; mod scanners { - pub mod fortify; pub mod blast; + pub mod fortify; pub mod parsers; } mod utils { - pub mod terminal; - pub mod generic; pub mod api; + pub mod generic; + pub mod terminal; } mod targets; -use std::str::FromStr; -use clap::{Parser, Subcommand, CommandFactory}; +use clap::{CommandFactory, Parser, Subcommand}; use config::Config; use scanners::fortify::parse as fortify_parse; +use std::str::FromStr; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -32,20 +32,26 @@ struct Cli { command: Option, #[arg(required = false)] - args: Vec, + args: Vec, } #[derive(Subcommand, Debug)] enum Commands { /// Authenticate to Corgea - Login { + Login { #[arg(help = "API token (if not provided, will use OAuth flow)")] token: Option, - #[arg(long, help = "The url of the corgea instance to use. defaults to https://www.corgea.app")] + #[arg( + long, + help = "The url of the corgea instance to use. defaults to https://www.corgea.app" + )] url: Option, - #[arg(long, help = "Scope to use for custom domain (e.g., 'ikea' for ikea.corgea.app). Only used with OAuth flow")] + #[arg( + long, + help = "Scope to use for custom domain (e.g., 'ikea' for ikea.corgea.app). Only used with OAuth flow" + )] scope: Option, }, /// Upload a scan report to Corgea via STDIN or a file @@ -65,13 +71,20 @@ enum Commands { #[arg(default_value = "blast")] scanner: Scanner, - #[arg(long, help = "Fail on (exits with error code 1) a specific severity level . Valid options are CR, HI, ME, LO.")] + #[arg( + long, + help = "Fail on (exits with error code 1) a specific severity level . Valid options are CR, HI, ME, LO." + )] fail_on: Option, #[arg(long, help = "Only scan uncommitted changes.")] only_uncommitted: bool, - #[arg(short, long, help = "Fail on (exits with error code 1) based on blocking rules defined in the web app.")] + #[arg( + short, + long, + help = "Fail on (exits with error code 1) based on blocking rules defined in the web app." + )] fail: bool, #[arg( @@ -88,10 +101,17 @@ enum Commands { )] scan_type: Option, - #[arg(long, help = "Output the result to a file in a specific format. Valid options are json, html, sarif, markdown.")] + #[arg( + long, + help = "Output the result to a file in a specific format. Valid options are json, html, sarif, markdown." + )] out_format: Option, - #[arg(short, long, help = "Output the result to a file. you can use the out_format option to specify the format of the output file.")] + #[arg( + short, + long, + help = "Output the result to a file. you can use the out_format option to specify the format of the output file." + )] out_file: Option, #[arg( @@ -107,16 +127,18 @@ enum Commands { project_name: Option, }, /// Wait for the latest in progress scan - Wait { - scan_id: Option, - }, + Wait { scan_id: Option }, /// List something, by default it lists the scans #[command(alias = "ls")] List { #[arg(short, long, help = "List issues instead of scans")] issues: bool, - #[arg(long, short = 'c', help = "List SCA (Software Composition Analysis) issues instead of regular issues")] + #[arg( + long, + short = 'c', + help = "List SCA (Software Composition Analysis) issues instead of regular issues" + )] sca_issues: bool, #[arg(short, long, help = "Specify the scan id to list issues for.")] @@ -129,7 +151,7 @@ enum Commands { json: bool, #[arg(long, value_parser = clap::value_parser!(u16), help = "Number of items per page")] - page_size: Option + page_size: Option, }, /// Inspect something, by default it will inspect a scan Inspect { @@ -140,20 +162,36 @@ enum Commands { #[arg(long, help = "Output the result in JSON format.")] json: bool, - #[arg(long, short, help = "Display a summary only of the issue in the output (only if --issue is true).")] + #[arg( + long, + short, + help = "Display a summary only of the issue in the output (only if --issue is true)." + )] summary: bool, - #[arg(long, short, help = "Display the fix explanations only in the output (only if --issue is true).")] + #[arg( + long, + short, + help = "Display the fix explanations only in the output (only if --issue is true)." + )] fix: bool, - #[arg(long, short, help = "Display the diff of the fix only in the output (only if --issue is true).")] + #[arg( + long, + short, + help = "Display the diff of the fix only in the output (only if --issue is true)." + )] diff: bool, id: String, }, /// Setup a git hook, currently only pre-commit is supported SetupHooks { - #[arg(long, short, help = "Include default config (scan types are pii, secrets and fail on levels are CR, HI, ME, LO).")] + #[arg( + long, + short, + help = "Include default config (scan types are pii, secrets and fail on levels are CR, HI, ME, LO)." + )] default_config: bool, }, } @@ -181,20 +219,18 @@ impl FromStr for Scanner { fn main() { let cli = Cli::parse(); let mut corgea_config = Config::load().expect("Failed to load config"); - fn verify_token_and_exit_when_fail (config: &Config) { + fn verify_token_and_exit_when_fail(config: &Config) { if config.get_token().is_empty() { eprintln!("No token set.\nPlease run 'corgea login' to authenticate.\nFor more info checkout our docs at Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli"); std::process::exit(1); } utils::api::set_auth_token(&config.get_token()); match utils::api::verify_token(config.get_url().as_str()) { - Ok(true) => { - return; - } + Ok(true) => {} Ok(false) => { println!("Invalid token provided.\nPlease run 'corgea login' to authenticate.\nFor more info checkout our docs at Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli"); std::process::exit(1); - }, + } Err(e) => { eprintln!("Error occurred: {}", e); std::process::exit(1); @@ -203,19 +239,34 @@ fn main() { } match &cli.command { Some(Commands::Login { token, url, scope }) => { - let effective_token = token.clone().or_else(|| utils::generic::get_env_var_if_exists("CORGEA_TOKEN")); - + let effective_token = token + .clone() + .or_else(|| utils::generic::get_env_var_if_exists("CORGEA_TOKEN")); + match effective_token { Some(token_value) => { - let token_source = if token.is_some() { "parameter" } else { "CORGEA_TOKEN environment variable" }; + let token_source = if token.is_some() { + "parameter" + } else { + "CORGEA_TOKEN environment variable" + }; utils::api::set_auth_token(&token_value); - match utils::api::verify_token(url.as_deref().unwrap_or(corgea_config.get_url().as_str())) { + match utils::api::verify_token( + url.as_deref().unwrap_or(corgea_config.get_url().as_str()), + ) { Ok(true) => { - corgea_config.set_token(token_value.clone()).expect("Failed to set token"); + corgea_config + .set_token(token_value.clone()) + .expect("Failed to set token"); if let Some(url) = url { - corgea_config.set_url(url.clone()).expect("Failed to set url"); + corgea_config + .set_url(url.clone()) + .expect("Failed to set url"); } - println!("Successfully authenticated to Corgea using token from {}.", token_source) + println!( + "Successfully authenticated to Corgea using token from {}.", + token_source + ) } Ok(false) => println!("Invalid token provided from {}.", token_source), Err(e) => { @@ -225,7 +276,7 @@ fn main() { } eprintln!("Error occurred: {}", e); std::process::exit(1); - }, + } } } // No token available - use OAuth flow @@ -233,9 +284,9 @@ fn main() { if url.is_some() && scope.is_some() { eprintln!("Warning: --url option is ignored when using OAuth flow with --scope. The scope determines the domain."); } - + match authorize::run(scope.clone(), url.clone()) { - Ok(()) => {}, + Ok(()) => {} Err(e) => { eprintln!("Authorization failed: {}", e); std::process::exit(1); @@ -244,7 +295,10 @@ fn main() { } } } - Some(Commands::Upload { report, project_name }) => { + Some(Commands::Upload { + report, + project_name, + }) => { verify_token_and_exit_when_fail(&corgea_config); match report { Some(report) => { @@ -259,7 +313,18 @@ fn main() { } } } - Some(Commands::Scan { scanner , fail_on, fail, only_uncommitted, scan_type, policy, out_format, out_file, target, project_name }) => { + Some(Commands::Scan { + scanner, + fail_on, + fail, + only_uncommitted, + scan_type, + policy, + out_format, + out_file, + target, + project_name, + }) => { verify_token_and_exit_when_fail(&corgea_config); if let Some(level) = fail_on { if *scanner != Scanner::Blast { @@ -292,7 +357,9 @@ fn main() { std::process::exit(1); } - if out_file.is_some() && !out_format.is_some() || !out_file.is_some() && out_format.is_some() { + if out_file.is_some() && !out_format.is_some() + || !out_file.is_some() && out_format.is_some() + { eprintln!("out_file and out_format must be used together."); std::process::exit(1); } @@ -342,14 +409,32 @@ fn main() { match scanner { Scanner::Snyk => scan::run_snyk(&corgea_config, project_name.clone()), Scanner::Semgrep => scan::run_semgrep(&corgea_config, project_name.clone()), - Scanner::Blast => scanners::blast::run(&corgea_config, fail_on.clone(), fail, only_uncommitted, scan_type.clone(), policy.clone(), out_format.clone(), out_file.clone(), target.clone(), project_name.clone()) + Scanner::Blast => scanners::blast::run( + &corgea_config, + fail_on.clone(), + fail, + only_uncommitted, + scan_type.clone(), + policy.clone(), + out_format.clone(), + out_file.clone(), + target.clone(), + project_name.clone(), + ), } } Some(Commands::Wait { scan_id }) => { verify_token_and_exit_when_fail(&corgea_config); wait::run(&corgea_config, scan_id.clone(), None); } - Some(Commands::List { issues , json, page, page_size, scan_id, sca_issues}) => { + Some(Commands::List { + issues, + json, + page, + page_size, + scan_id, + sca_issues, + }) => { verify_token_and_exit_when_fail(&corgea_config); if *issues && *sca_issues { eprintln!("Cannot use both --issues and --sca-issues at the same time."); @@ -359,9 +444,24 @@ fn main() { println!("scan_id option is only supported for issues list command."); std::process::exit(1); } - list::run(&corgea_config, issues, sca_issues, json, page, page_size, scan_id); + list::run( + &corgea_config, + issues, + sca_issues, + json, + page, + page_size, + scan_id, + ); } - Some(Commands::Inspect { issue, json, id, summary, fix, diff }) => { + Some(Commands::Inspect { + issue, + json, + id, + summary, + fix, + diff, + }) => { verify_token_and_exit_when_fail(&corgea_config); inspect::run(&corgea_config, issue, json, summary, fix, diff, id) } diff --git a/src/scan.rs b/src/scan.rs index 184dbdd..d657bc5 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -1,14 +1,14 @@ +use crate::cicd::*; +use crate::log::debug; +use crate::scanners::parsers::ScanParserFactory; +use crate::{utils, Config}; +use reqwest::header; +use serde_json::Value; use std::collections::HashSet; use std::io::{self, Read}; -use crate::{utils, Config}; -use uuid::Uuid; use std::path::Path; use std::process::Command; -use crate::cicd::{*}; -use crate::log::debug; -use reqwest::header; -use crate::scanners::parsers::ScanParserFactory; -use serde_json::Value; +use uuid::Uuid; pub fn run_command(base_cmd: &String, mut command: Command) -> String { match which::which(base_cmd) { @@ -30,7 +30,7 @@ pub fn run_command(base_cmd: &String, mut command: Command) -> String { std::process::exit(1); } - return stdout; + stdout } else { let stderr = String::from_utf8(output.stderr).expect("Failed to parse stderr"); let stdout = String::from_utf8(output.stdout).expect("Failed to parse stdout"); @@ -55,7 +55,11 @@ pub fn run_semgrep(config: &Config, project_name: Option) { println!("Scanning with semgrep..."); let base_command = "semgrep"; let mut command = std::process::Command::new(base_command); - command.arg("scan").arg("--config").arg("auto").arg("--json"); + command + .arg("scan") + .arg("--config") + .arg("auto") + .arg("--json"); println!("Running \"semgrep scan --config auto --json\""); @@ -100,7 +104,12 @@ pub fn read_file_report(config: &Config, file_path: &str, project_name: Option) -> Option { +pub fn parse_scan( + config: &Config, + input: String, + save_to_file: bool, + project_name: Option, +) -> Option { debug("Parsing the scan report"); // Remove BOM (Byte Order Mark) if present @@ -115,7 +124,14 @@ pub fn parse_scan(config: &Config, input: String, save_to_file: bool, project_na std::process::exit(0); } - return upload_scan(config, parse_result.paths, parse_result.scanner, cleaned_input.to_string(), save_to_file, project_name); + upload_scan( + config, + parse_result.paths, + parse_result.scanner, + cleaned_input.to_string(), + save_to_file, + project_name, + ) } Err(error_message) => { @@ -125,7 +141,14 @@ pub fn parse_scan(config: &Config, input: String, save_to_file: bool, project_na } } -pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: String, save_to_file: bool, project_name: Option) -> Option { +pub fn upload_scan( + config: &Config, + paths: Vec, + scanner: String, + input: String, + save_to_file: bool, + project_name: Option, +) -> Option { let in_ci = running_in_ci(); let ci_platform = which_ci(); let github_env_vars = get_github_env_vars(); @@ -133,30 +156,38 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: let run_id = Uuid::new_v4().to_string(); let base_url = config.get_url(); let api_base = "/api/v1"; - let project; - if in_ci { + let project = if in_ci { debug("Running in CI"); - project = format!("{}-{}", - github_env_vars.get("GITHUB_REPOSITORY").expect("Failed to get GITHUB_REPOSITORY").to_string(), - github_env_vars.get("GITHUB_PR").expect("Failed to get GITHUB_REPOSITORY").to_string()) + format!( + "{}-{}", + github_env_vars + .get("GITHUB_REPOSITORY") + .expect("Failed to get GITHUB_REPOSITORY"), + github_env_vars + .get("GITHUB_PR") + .expect("Failed to get GITHUB_REPOSITORY") + ) } else { - project = utils::generic::determine_project_name(project_name.as_deref()); - } + utils::generic::determine_project_name(project_name.as_deref()) + }; let repo_data = std::env::var("REPO_DATA").unwrap_or_else(|_| "".to_string()); let scan_upload_url = if repo_data.is_empty() { format!( - "{}{}/scan-upload?engine={}&run_id={}&project={}&ci={}&ci_platform={}", base_url, api_base, scanner, run_id, project, in_ci, ci_platform + "{}{}/scan-upload?engine={}&run_id={}&project={}&ci={}&ci_platform={}", + base_url, api_base, scanner, run_id, project, in_ci, ci_platform ) } else { format!( - "{}{}/scan-upload?engine={}&run_id={}&project={}&ci={}&ci_platform={}&repo_data={}", base_url, api_base, scanner, run_id, project, in_ci, ci_platform, repo_data + "{}{}/scan-upload?engine={}&run_id={}&project={}&ci={}&ci_platform={}&repo_data={}", + base_url, api_base, scanner, run_id, project, in_ci, ci_platform, repo_data ) }; let git_config_upload_url = format!( - "{}{}/git-config-upload?run_id={}", base_url, api_base, run_id + "{}{}/git-config-upload?run_id={}", + base_url, api_base, run_id ); let client = utils::api::http_client(); @@ -168,7 +199,10 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: for path in &paths { if !Path::new(&path).exists() { - eprintln!("Required file {} not found which is required for the scan, exiting.", path); + eprintln!( + "Required file {} not found which is required for the scan, exiting.", + path + ); std::process::exit(1); } @@ -177,7 +211,8 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: } let src_upload_url = format!( - "{}{}/code-upload?run_id={}&path={}", base_url, api_base, run_id, path + "{}{}/code-upload?run_id={}&path={}", + base_url, api_base, run_id, path ); debug(&format!("Uploading file: {}", path)); let fp = Path::new(&path); @@ -191,16 +226,19 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: .expect("Failed to read file"); debug(&format!("POST: {}", src_upload_url)); - let res = client.post(&src_upload_url) - .multipart(form) - .send(); + let res = client.post(&src_upload_url).multipart(form).send(); match res { Ok(response) => { if !response.status().is_success() { let status = response.status(); - let body = response.text().unwrap_or_else(|_| "Unable to read response body".to_string()); - debug(&format!("Code upload failed with status: {}. Response body: {}", status, body)); + let body = response + .text() + .unwrap_or_else(|_| "Unable to read response body".to_string()); + debug(&format!( + "Code upload failed with status: {}. Response body: {}", + status, body + )); eprintln!("Failed to upload file {} {}... retrying", status, path); std::thread::sleep(std::time::Duration::from_secs(1)); attempts += 1; @@ -219,7 +257,10 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: if attempts == 3 && !success { upload_error_count += 1; - eprintln!("Failed to upload file: {} after 3 attempts. skipping...", path); + eprintln!( + "Failed to upload file: {} after 3 attempts. skipping...", + path + ); } } @@ -235,30 +276,34 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: let input_size = input_bytes.len(); let max_upload_size = 50 * 1024 * 1024; // 50mb let chunk_size = match std::env::var("DEBUG_CORGEA_OVERRIDE_REPORT_CHUNK_SIZE") { - Ok(val) => { - match val.parse::() { - Ok(mb) if mb > 0 => { - debug(&format!("Overriding report chunk size to {} MB", mb)); - mb * 1024 * 1024 - } - _ => { - eprintln!("Invalid DEBUG_CORGEA_OVERRIDE_REPORT_CHUNK_SIZE value '{}', using default 1 MB", val); - 1024 * 1024 - } + Ok(val) => match val.parse::() { + Ok(mb) if mb > 0 => { + debug(&format!("Overriding report chunk size to {} MB", mb)); + mb * 1024 * 1024 } - } + _ => { + eprintln!("Invalid DEBUG_CORGEA_OVERRIDE_REPORT_CHUNK_SIZE value '{}', using default 1 MB", val); + 1024 * 1024 + } + }, Err(_) => 1024 * 1024, // default 1mb }; let is_chunked = input_size > max_upload_size; let res = if is_chunked { - let total_chunks = (input_size + chunk_size - 1) / chunk_size; + let total_chunks = input_size.div_ceil(chunk_size); debug(&format!("Uploading scan in {} chunks", total_chunks)); let mut offset = 0usize; let mut last_response = None; for (index, chunk) in input_bytes.chunks(chunk_size).enumerate() { - debug(&format!("POST: {} (chunk {}/{})", scan_upload_url, index + 1, total_chunks)); - let response = client.post(&scan_upload_url) + debug(&format!( + "POST: {} (chunk {}/{})", + scan_upload_url, + index + 1, + total_chunks + )); + let response = client + .post(&scan_upload_url) .header(header::CONTENT_TYPE, "application/json") .header("Upload-Offset", offset.to_string()) .header("Upload-Length", input_size.to_string()) @@ -295,7 +340,7 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: false } } - }, + } Err(_) => true, }; last_response = Some(response); @@ -308,7 +353,8 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: last_response.expect("Failed to upload scan.") } else { debug(&format!("POST: {}", scan_upload_url)); - client.post(&scan_upload_url) + client + .post(&scan_upload_url) .header(header::CONTENT_TYPE, "application/json") .body(input.clone()) .send() @@ -365,8 +411,13 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: } else { upload_failed = true; let status = response.status(); - let body = response.text().unwrap_or_else(|_| "Unable to read response body".to_string()); - debug(&format!("Scan upload failed with status: {}. Response body: {}", status, body)); + let body = response + .text() + .unwrap_or_else(|_| "Unable to read response body".to_string()); + debug(&format!( + "Scan upload failed with status: {}. Response body: {}", + status, body + )); eprintln!("Failed to upload scan: {}", status); } } @@ -376,7 +427,6 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: } } - let git_config_path = Path::new(".git/config"); if git_config_path.exists() { @@ -386,9 +436,7 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: .expect("Failed to read file"); debug(&format!("POST: {}", git_config_upload_url)); - let res = client.post(&git_config_upload_url) - .multipart(form) - .send(); + let res = client.post(&git_config_upload_url).multipart(form).send(); match res { Ok(response) => { @@ -404,7 +452,8 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: if in_ci { let ci_data_upload_url = format!( - "{}{}/ci-data-upload?run_id={}&platform={}", base_url, api_base, run_id, ci_platform + "{}{}/ci-data-upload?run_id={}&platform={}", + base_url, api_base, run_id, ci_platform ); let mut github_env_vars_json = serde_json::Map::new(); @@ -421,7 +470,8 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: }; debug(&format!("POST: {}", ci_data_upload_url)); - let _res = client.post(ci_data_upload_url) + let _res = client + .post(ci_data_upload_url) .header(header::CONTENT_TYPE, "application/json") .body(github_env_vars_json_string) .send(); @@ -433,7 +483,7 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: match std::fs::write(&file_path, input.clone()) { Ok(_) => println!("Successfully saved scan to {}", file_path.display()), - Err(e) => eprintln!("Failed to save scan to {}: {}", file_path.display(), e) + Err(e) => eprintln!("Failed to save scan to {}: {}", file_path.display(), e), } } @@ -441,13 +491,22 @@ pub fn upload_scan(config: &Config, paths: Vec, scanner: String, input: std::process::exit(1); } - println!("Successfully scanned using {} and uploaded to Corgea.", scanner); + println!( + "Successfully scanned using {} and uploaded to Corgea.", + scanner + ); if upload_error_count > 0 { - println!("Failed to upload {} files, you may not see all fixes in Corgea.", upload_error_count); + println!( + "Failed to upload {} files, you may not see all fixes in Corgea.", + upload_error_count + ); } println!("Go to {base_url} to see results."); - sast_scan_id.map(|scan_id| ScanUploadResult { scan_id, project_id }) + sast_scan_id.map(|scan_id| ScanUploadResult { + scan_id, + project_id, + }) } diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index d530ed8..e712ddb 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -1,20 +1,19 @@ -use crate::utils; use crate::config::Config; use crate::targets; +use crate::utils; use std::collections::HashMap; -use std::sync::{Arc, Mutex}; -use std::error::Error; -use std::thread; use std::env; +use std::error::Error; use std::fs; +use std::sync::{Arc, Mutex}; +use std::thread; use uuid::Uuid; - - +#[allow(clippy::too_many_arguments)] pub fn run( - config: &Config, - fail_on: Option, - fail: &bool, + config: &Config, + fail_on: Option, + fail: &bool, only_uncommitted: &bool, scan_type: Option, policy: Option, @@ -34,37 +33,33 @@ pub fn run( Ok(false) => { eprintln!("This is not a git repository. Without a git repository Corgea CLI can't determine which files have been modified or added thus only a full scan is possible."); std::process::exit(1); - }, + } Err(e) => { eprintln!("Error checking git repository information: {}. Without a git repository Corgea CLI can't determine which files have been modified or added thus only a full scan is possible.", e); std::process::exit(1); - }, + } Ok(true) => { // Continue with the git repo logic } } } - println!( - "\nScanning with BLAST 🚀🚀🚀" - ); + println!("\nScanning with BLAST 🚀🚀🚀"); if let Some(scan_type) = &scan_type { println!("Running Scan Type: {}", scan_type); } if let Some(policy) = &policy { - println!("Including only specified policies for policy scan: {}", policy); + println!( + "Including only specified policies for policy scan: {}", + policy + ); } println!("\n\n"); let temp_dir = env::temp_dir().join(format!("corgea/tmp/{}", Uuid::new_v4())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let project_name = utils::generic::determine_project_name(project_name.as_deref()); let zip_path = format!("{}/{}.zip", temp_dir.display(), project_name); - let repo_info = match utils::generic::get_repo_info("./") { - Ok(info) => info, - Err(_) => { - None - } - }; + let repo_info = utils::generic::get_repo_info("./").unwrap_or_default(); match utils::generic::create_path_if_not_exists(&temp_dir) { Ok(_) => (), Err(e) => { @@ -79,7 +74,10 @@ pub fn run( let stop_signal = Arc::new(Mutex::new(false)); let stop_signal_clone = Arc::clone(&stop_signal); let packaging_thread = thread::spawn(move || { - utils::terminal::show_loading_message("Packaging your project... ([T]s)", stop_signal_clone); + utils::terminal::show_loading_message( + "Packaging your project... ([T]s)", + stop_signal_clone, + ); }); let target_str: Option<&str> = if *only_uncommitted { @@ -94,7 +92,10 @@ pub fn run( if result.files.is_empty() { *stop_signal.lock().unwrap() = true; let _ = packaging_thread.join(); - print!("\r{}", utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset)); + print!( + "\r{}", + utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset) + ); eprintln!("\n\nError: target resolved to zero files.\n"); eprintln!("Target value: {}\n", target_value); eprintln!("Segment results:"); @@ -102,7 +103,10 @@ pub fn run( if let Some(ref error) = segment_result.error { eprintln!(" {}: ERROR - {}", segment_result.segment, error); } else { - eprintln!(" {}: {} matches", segment_result.segment, segment_result.matches); + eprintln!( + " {}: {} matches", + segment_result.segment, segment_result.matches + ); } } eprintln!("\nPlease check your target specification and try again.\n"); @@ -113,7 +117,9 @@ pub fn run( if *only_uncommitted { println!("\rFiles to be submitted for partial scan:\n"); for (index, file) in result.files.iter().enumerate() { - if let Ok(relative) = file.strip_prefix(std::env::current_dir().unwrap_or_default()) { + if let Ok(relative) = + file.strip_prefix(std::env::current_dir().unwrap_or_default()) + { println!("{}: {}", index + 1, relative.display()); } else { println!("{}: {}", index + 1, file.display()); @@ -122,10 +128,12 @@ pub fn run( println!(); } else { println!("Scanning {} files (target mode)", file_count); - + let display_count = std::cmp::min(20, file_count); for file in result.files.iter().take(display_count) { - if let Ok(relative) = file.strip_prefix(std::env::current_dir().unwrap_or_default()) { + if let Ok(relative) = + file.strip_prefix(std::env::current_dir().unwrap_or_default()) + { println!(" {}", relative.display()); } else { println!(" {}", file.display()); @@ -140,7 +148,10 @@ pub fn run( Err(e) => { *stop_signal.lock().unwrap() = true; let _ = packaging_thread.join(); - print!("\r{}", utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset)); + print!( + "\r{}", + utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset) + ); eprintln!("\n\nError resolving targets: {}\n", e); std::process::exit(1); } @@ -152,23 +163,27 @@ pub fn run( if added_files.is_empty() { *stop_signal.lock().unwrap() = true; let _ = packaging_thread.join(); - print!("\r{}", utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset)); + print!( + "\r{}", + utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset) + ); if *only_uncommitted { eprintln!( "\n\nOops! It seems there are no scannable uncommitted changes in your project.\nYou may have uncommitted changes, but none match the types of files we can scan.\n\n" ); } else { - eprintln!( - "\n\nOops! No valid files found to scan after filtering.\n\n" - ); + eprintln!("\n\nOops! No valid files found to scan after filtering.\n\n"); } std::process::exit(1); } - }, + } Err(e) => { *stop_signal.lock().unwrap() = true; let _ = packaging_thread.join(); - print!("\r{}", utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset)); + print!( + "\r{}", + utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset) + ); eprintln!( "\n\nUh-oh! We couldn't package your project at '{}'.\nThis might be due to insufficient permissions, invalid file paths, or a file system error.\nPlease check the directory and try again.\nError details:\n{}\n\n", zip_path, e @@ -178,9 +193,19 @@ pub fn run( } *stop_signal.lock().unwrap() = true; let _ = packaging_thread.join(); - print!("\r{}Project packaged successfully.\n", utils::terminal::set_text_color("", utils::terminal::TerminalColor::Green)); + print!( + "\r{}Project packaged successfully.\n", + utils::terminal::set_text_color("", utils::terminal::TerminalColor::Green) + ); println!("\n\nSubmitting scan to Corgea:"); - let upload_result = match utils::api::upload_zip(&zip_path, &config.get_url(), &project_name, repo_info, scan_type, policy) { + let upload_result = match utils::api::upload_zip( + &zip_path, + &config.get_url(), + &project_name, + repo_info, + scan_type, + policy, + ) { Ok(result) => result, Err(e) => { eprintln!("\n\nOh no! We encountered an issue while uploading the zip file '{}' to the server.\nPlease ensure that: @@ -197,13 +222,18 @@ pub fn run( e ); std::process::exit(1); - }, + } }; let scan_id = upload_result.scan_id; let scan_url = match &upload_result.project_id { Some(pid) => format!("{}/project/{}/?scan_id={}", config.get_url(), pid, scan_id), - None => format!("{}/project/{}?scan_id={}", config.get_url(), project_name, scan_id), + None => format!( + "{}/project/{}?scan_id={}", + config.get_url(), + project_name, + scan_id + ), }; let _ = utils::generic::delete_directory(&temp_dir); @@ -222,7 +252,10 @@ pub fn run( let stop_signal = Arc::new(Mutex::new(false)); let stop_signal_clone = Arc::clone(&stop_signal); let results_thread = thread::spawn(move || { - utils::terminal::show_loading_message("Collecting scan results... ([T]s)", stop_signal_clone); + utils::terminal::show_loading_message( + "Collecting scan results... ([T]s)", + stop_signal_clone, + ); }); let classifications = match report_scan_status(&config.get_url(), &project_name) { @@ -234,7 +267,7 @@ pub fn run( utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green) ); issues_classes - }, + } Err(e) => { *stop_signal.lock().unwrap() = true; let _ = results_thread.join(); @@ -247,7 +280,10 @@ pub fn run( - Error details: {}\n", utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset), utils::terminal::set_text_color( - &format!("Failed to report the scan status for project: '{}'.", project_name), + &format!( + "Failed to report the scan status for project: '{}'.", + project_name + ), utils::terminal::TerminalColor::Red ), utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Blue), @@ -258,13 +294,14 @@ pub fn run( } }; if *fail { - let blocking_rules = match utils::api::check_blocking_rules(&config.get_url(), &scan_id, None) { - Ok(rules) => rules, - Err(e) => { - eprintln!("Failed to check blocking rules: {}", e); - std::process::exit(1); - } - }; + let blocking_rules = + match utils::api::check_blocking_rules(&config.get_url(), &scan_id, None) { + Ok(rules) => rules, + Err(e) => { + eprintln!("Failed to check blocking rules: {}", e); + std::process::exit(1); + } + }; if blocking_rules.block { println!("\nExiting with error code 1 due to some issues violating some blocking rules defined for this project.\nfor more details, please check the scan results at the link: {}\nAlternatively, you can run {} to view the issues list on your local machine.", utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green), @@ -282,18 +319,29 @@ pub fn run( let stop_signal = Arc::new(Mutex::new(false)); let stop_signal_clone = Arc::clone(&stop_signal); let results_thread = thread::spawn(move || { - utils::terminal::show_loading_message("Generating scan report... ([T]s)", stop_signal_clone); + utils::terminal::show_loading_message( + "Generating scan report... ([T]s)", + stop_signal_clone, + ); }); if out_format == "json" { - let issues = match utils::api::get_all_issues(&config.get_url(), &project_name, Some(scan_id.clone())) { + let issues = match utils::api::get_all_issues( + &config.get_url(), + &project_name, + Some(scan_id.clone()), + ) { Ok(issues) => issues, Err(e) => { eprintln!("\n\nFailed to fetch issues: {}\n\n", e); std::process::exit(1); } }; - let sca_issues = match utils::api::get_all_sca_issues(&config.get_url(), &project_name, Some(scan_id.clone())) { + let sca_issues = match utils::api::get_all_sca_issues( + &config.get_url(), + &project_name, + Some(scan_id.clone()), + ) { Ok(issues) => issues, Err(e) => { eprintln!("\n\nFailed to fetch SCA issues: {}\n\n", e); @@ -302,15 +350,17 @@ pub fn run( }; let json = serde_json::to_string_pretty(&issues).unwrap(); let sca_json = serde_json::to_string_pretty(&sca_issues).unwrap(); - let report_json= serde_json::to_string_pretty(&classifications).unwrap(); - let results_json = format!("{{\"issues\": {}, \"sca_issues\": {}, \"report\": {}}}", json, sca_json, report_json); + let report_json = serde_json::to_string_pretty(&classifications).unwrap(); + let results_json = format!( + "{{\"issues\": {}, \"sca_issues\": {}, \"report\": {}}}", + json, sca_json, report_json + ); *stop_signal.lock().unwrap() = true; let _ = results_thread.join(); fs::write(out_file.clone(), results_json).expect("Failed to write JSON file, check if the file path is valid and you have the necessary permissions to write to it."); utils::terminal::clear_previous_line(); println!("\n\nScan results written to: {}\n\n", out_file.clone()); - } - else if out_format == "html" { + } else if out_format == "html" { let report = match utils::api::get_scan_report(&config.get_url(), &scan_id, None) { Ok(html) => html, Err(e) => { @@ -323,23 +373,26 @@ pub fn run( fs::write(out_file.clone(), report).expect("\n\nFailed to write HTML file, check if the file path is valid and you have the necessary permissions to write to it."); utils::terminal::clear_previous_line(); println!("\n\nScan report written to: {}\n\n", out_file.clone()); - } - else if out_format == "sarif" { - let report = match utils::api::get_scan_report(&config.get_url(), &scan_id, Some("sarif")) { - Ok(sarif) => sarif, - Err(e) => { - eprintln!("\n\nFailed to fetch SARIF report: {}\n\n", e); - std::process::exit(1); - } - }; + } else if out_format == "sarif" { + let report = + match utils::api::get_scan_report(&config.get_url(), &scan_id, Some("sarif")) { + Ok(sarif) => sarif, + Err(e) => { + eprintln!("\n\nFailed to fetch SARIF report: {}\n\n", e); + std::process::exit(1); + } + }; *stop_signal.lock().unwrap() = true; let _ = results_thread.join(); fs::write(out_file.clone(), report).expect("\n\nFailed to write SARIF file, check if the file path is valid and you have the necessary permissions to write to it."); utils::terminal::clear_previous_line(); println!("\n\nScan report written to: {}\n\n", out_file.clone()); - } - else if out_format == "markdown" { - let report = match utils::api::get_scan_report(&config.get_url(), &scan_id, Some("markdown")) { + } else if out_format == "markdown" { + let report = match utils::api::get_scan_report( + &config.get_url(), + &scan_id, + Some("markdown"), + ) { Ok(markdown) => markdown, Err(e) => { eprintln!("\n\nFailed to fetch Markdown report: {}\n\n", e); @@ -359,100 +412,96 @@ pub fn run( if let Some(fail_on) = fail_on { match fail_on.as_str() { - "LO" => { - if classifications.values().any(|&count| count > 0) { - std::process::exit(1); - } - }, - "ME" => { - if classifications.get("ME").map_or(false, |&count| count > 0) || - classifications.get("HI").map_or(false, |&count| count > 0) { - std::process::exit(1); - } - }, - "HI" => { - if classifications.get("CR").map_or(false, |&count| count > 0) || - classifications.get("HI").map_or(false, |&count| count > 0) { - std::process::exit(1); - } - }, + "LO" if classifications.values().any(|&count| count > 0) => { + std::process::exit(1); + } + "ME" if (classifications.get("ME").is_some_and(|&count| count > 0) + || classifications.get("HI").is_some_and(|&count| count > 0)) => + { + std::process::exit(1); + } + "HI" if (classifications.get("CR").is_some_and(|&count| count > 0) + || classifications.get("HI").is_some_and(|&count| count > 0)) => + { + std::process::exit(1); + } "CR" => { if let Some(cr_count) = classifications.get("CR") { if *cr_count > 0 { std::process::exit(1); } } - }, + } _ => (), } } - - } pub fn wait_for_scan(config: &Config, scan_id: &str) { - // Create loading animation - let stop_signal = Arc::new(Mutex::new(false)); + // Create loading animation + let stop_signal = Arc::new(Mutex::new(false)); - // Spawn a new thread for the spinner animation - let stop_signal_clone = Arc::clone(&stop_signal); - thread::spawn(move || { - utils::terminal::show_loading_message("Scanning... The Hunt Is On! ([T]s)", stop_signal_clone); - }); - - loop { - std::thread::sleep(std::time::Duration::from_secs(1)); - match check_scan_status(&scan_id, &config.get_url()) { - Ok(true) => { - *stop_signal.lock().unwrap() = true; - break; - }, - Ok(false) => { }, - Err(e) => { - eprintln!( - "\n\nUnable to check the scan status for scan ID '{}'.\nPlease verify that: + // Spawn a new thread for the spinner animation + let stop_signal_clone = Arc::clone(&stop_signal); + thread::spawn(move || { + utils::terminal::show_loading_message( + "Scanning... The Hunt Is On! ([T]s)", + stop_signal_clone, + ); + }); + + loop { + std::thread::sleep(std::time::Duration::from_secs(1)); + match check_scan_status(scan_id, &config.get_url()) { + Ok(true) => { + *stop_signal.lock().unwrap() = true; + break; + } + Ok(false) => {} + Err(e) => { + eprintln!( + "\n\nUnable to check the scan status for scan ID '{}'.\nPlease verify that: - The server URL '{}' is reachable. - Your authentication token is valid. - The scan ID '{}' exists and is correct. Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli - Error details:\n{}", - scan_id, - config.get_url(), - scan_id, - e - ); - std::process::exit(1); - } + Error details:\n{}", + scan_id, + config.get_url(), + scan_id, + e + ); + std::process::exit(1); } } - print!("{}", utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset)); - println!( - "\r╭────────────────────────────────────────────╮\n\ + } + print!( + "{}", + utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset) + ); + println!( + "\r╭────────────────────────────────────────────╮\n\ │ {: <42} │\n\ │ 🎉🎉 Scan Completed Successfully! 🎉🎉 │\n\ │ {: <42} │\n\ ╰────────────────────────────────────────────╯\n", - " ", - " " - ); - - - - + " ", " " + ); } - pub fn check_scan_status(scan_id: &str, url: &str) -> Result> { match utils::api::get_scan(url, scan_id) { Ok(scan) => Ok(scan.status == "complete"), - Err(e) => Err(e) + Err(e) => Err(e), } } - -pub fn fetch_and_group_scan_issues(url: &str, project: &str) -> Result, Box> { +pub fn fetch_and_group_scan_issues( + url: &str, + project: &str, +) -> Result, Box> { let issues = match utils::api::get_all_issues(url, project, None) { Ok(issues) => issues, Err(err) => { @@ -462,13 +511,18 @@ pub fn fetch_and_group_scan_issues(url: &str, project: &str) -> Result = HashMap::new(); if !issues.is_empty() { for issue in &issues { - *classification_counts.entry(issue.urgency.clone()).or_insert(0) += 1; + *classification_counts + .entry(issue.urgency.clone()) + .or_insert(0) += 1; } } Ok(classification_counts) } -pub fn report_scan_status(url: &str, project: &str) -> Result, Box>{ +pub fn report_scan_status( + url: &str, + project: &str, +) -> Result, Box> { let classification_counts = match fetch_and_group_scan_issues(url, project) { Ok(counts) => counts, Err(e) => { @@ -479,8 +533,8 @@ pub fn report_scan_status(url: &str, project: &str) -> Result(); utils::terminal::clear_previous_line(); println!("\rScan Results:-\n"); - println!("{:<20} | {}", "Classification", "Count"); - println!("{:-<20} | {}", "", ""); + println!("{:<20} | Count", "Classification"); + println!("{:-<20} | ", ""); let order = vec!["CR", "HI", "ME", "LO"]; for classification in order { @@ -491,8 +545,7 @@ pub fn report_scan_status(url: &str, project: &str) -> Result) { let temp_dir = match TempDir::new() { @@ -48,7 +48,14 @@ pub fn parse(config: &Config, file_path: &str, project_name: Option) { } let (scan_data, paths) = extract_file_path(outpath); - let _scan_id = upload_scan(config, paths, "fortify".to_string(), scan_data, false, project_name); + let _scan_id = upload_scan( + config, + paths, + "fortify".to_string(), + scan_data, + false, + project_name, + ); } else { println!("File 'audit.fvdl' not found in the archive"); }; @@ -61,7 +68,9 @@ fn extract_file_path(scan_file: PathBuf) -> (String, Vec) { let mut reader = BufReader::new(file); let mut contents = String::new(); - reader.read_to_string(&mut contents).expect("Unable to read file"); + reader + .read_to_string(&mut contents) + .expect("Unable to read file"); let mut xml_reader = Reader::from_str(&contents); xml_reader.config_mut().trim_text(true); diff --git a/src/scanners/parsers/checkmarx.rs b/src/scanners/parsers/checkmarx.rs index f8da40f..4fda0f2 100644 --- a/src/scanners/parsers/checkmarx.rs +++ b/src/scanners/parsers/checkmarx.rs @@ -1,8 +1,8 @@ -use serde_json::Value; +use super::{ParseResult, ScanParser}; use crate::log::debug; -use super::{ScanParser, ParseResult}; -use quick_xml::Reader; use quick_xml::events::Event; +use quick_xml::Reader; +use serde_json::Value; pub struct CheckmarxCliParser; @@ -79,13 +79,22 @@ impl ScanParser for CheckmarxWebParser { for language in languages { if let Some(queries) = language.get("queries").and_then(|v| v.as_array()) { for query in queries { - if let Some(vulns) = query.get("vulnerabilities").and_then(|v| v.as_array()) { + if let Some(vulns) = + query.get("vulnerabilities").and_then(|v| v.as_array()) + { for vuln in vulns { - if let Some(nodes) = vuln.get("nodes").and_then(|v| v.as_array()) { + if let Some(nodes) = + vuln.get("nodes").and_then(|v| v.as_array()) + { for node in nodes { if let Some(path) = node.get("fileName") { if let Some(truncated_path) = path.as_str() { - paths.push(truncated_path.get(1..).unwrap_or("").to_string()); + paths.push( + truncated_path + .get(1..) + .unwrap_or("") + .to_string(), + ); } } } @@ -124,14 +133,13 @@ impl CheckmarxXmlParser { match reader.read_event_into(&mut buf) { Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { if e.name().as_ref() == b"Result" { - for attr in e.attributes() { - if let Ok(attr) = attr { - if attr.key.as_ref() == b"FileName" { - if let Ok(file_name) = std::str::from_utf8(&attr.value) { - let clean_path = file_name.trim_start_matches('/').trim_start_matches('\\'); - if !clean_path.is_empty() { - paths.push(clean_path.to_string()); - } + for attr in e.attributes().flatten() { + if attr.key.as_ref() == b"FileName" { + if let Ok(file_name) = std::str::from_utf8(&attr.value) { + let clean_path = + file_name.trim_start_matches('/').trim_start_matches('\\'); + if !clean_path.is_empty() { + paths.push(clean_path.to_string()); } } } @@ -139,7 +147,8 @@ impl CheckmarxXmlParser { } else if e.name().as_ref() == b"FileName" { if let Ok(Event::Text(text)) = reader.read_event_into(&mut buf) { if let Ok(file_name) = std::str::from_utf8(text.as_ref()) { - let clean_path = file_name.trim_start_matches('/').trim_start_matches('\\'); + let clean_path = + file_name.trim_start_matches('/').trim_start_matches('\\'); if !clean_path.is_empty() { paths.push(clean_path.to_string()); } diff --git a/src/scanners/parsers/coverity.rs b/src/scanners/parsers/coverity.rs index 1d3f5d7..80c7109 100644 --- a/src/scanners/parsers/coverity.rs +++ b/src/scanners/parsers/coverity.rs @@ -23,17 +23,13 @@ impl ScanParser for CoverityParser { let is_merged_defect = e.name().as_ref() == b"cov:mergedDefect" || e.name().as_ref() == b"mergedDefect"; if is_merged_defect { - for attr in e.attributes() { - if let Ok(attr) = attr { - if attr.key.as_ref() == b"file" { - if let Ok(file_path) = std::str::from_utf8(attr.value.as_ref()) - { - let clean_path = file_path - .trim_start_matches('/') - .trim_start_matches('\\'); - if !clean_path.is_empty() { - paths.push(clean_path.to_string()); - } + for attr in e.attributes().flatten() { + if attr.key.as_ref() == b"file" { + if let Ok(file_path) = std::str::from_utf8(attr.value.as_ref()) { + let clean_path = + file_path.trim_start_matches('/').trim_start_matches('\\'); + if !clean_path.is_empty() { + paths.push(clean_path.to_string()); } } } diff --git a/src/scanners/parsers/mod.rs b/src/scanners/parsers/mod.rs index 8311935..cae9ae6 100644 --- a/src/scanners/parsers/mod.rs +++ b/src/scanners/parsers/mod.rs @@ -1,5 +1,3 @@ - - #[derive(Debug)] pub struct ParseResult { pub paths: Vec, @@ -34,8 +32,11 @@ impl ScanParserFactory { } #[allow(dead_code)] - pub fn find_parser(&self, input: &str) -> Option<&Box> { - self.parsers.iter().find(|parser| parser.detect(input)) + pub fn find_parser(&self, input: &str) -> Option<&dyn ScanParser> { + self.parsers + .iter() + .find(|parser| parser.detect(input)) + .map(|b| b.as_ref()) } pub fn parse_scan_data(&self, input: &str) -> Result { @@ -53,7 +54,7 @@ impl ScanParserFactory { } } -pub mod semgrep; -pub mod sarif; pub mod checkmarx; pub mod coverity; +pub mod sarif; +pub mod semgrep; diff --git a/src/scanners/parsers/sarif.rs b/src/scanners/parsers/sarif.rs index d9b1956..4781bda 100644 --- a/src/scanners/parsers/sarif.rs +++ b/src/scanners/parsers/sarif.rs @@ -1,29 +1,38 @@ -use serde_json::Value; +use super::{ParseResult, ScanParser}; use crate::log::debug; -use super::{ScanParser, ParseResult}; +use serde_json::Value; pub struct SarifParser; impl ScanParser for SarifParser { fn detect(&self, input: &str) -> bool { if let Ok(data) = serde_json::from_str::(input) { - let schema = data.get("$schema").and_then(|v| v.as_str()).unwrap_or("unknown"); + let schema = data + .get("$schema") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); schema.contains("sarif") } else { false } } - + fn parse(&self, input: &str) -> Option { debug("Detected sarif schema"); - + let data: Value = match serde_json::from_str(input) { Ok(data) => data, Err(_) => return None, }; - - let run = data.get("runs").and_then(|v| v.as_array()).and_then(|v| v.get(0)); - let driver = run.and_then(|v| v.get("tool")).and_then(|v| v.get("driver")).and_then(|v| v.get("name")); + + let run = data + .get("runs") + .and_then(|v| v.as_array()) + .and_then(|v| v.first()); + let driver = run + .and_then(|v| v.get("tool")) + .and_then(|v| v.get("driver")) + .and_then(|v| v.get("name")); let tool = driver.and_then(|v| v.as_str()).unwrap_or("unknown"); let scanner = match tool { @@ -46,12 +55,15 @@ impl ScanParser for SarifParser { for run in runs { if let Some(results) = run.get("results").and_then(|v| v.as_array()) { for result in results { - if let Some(locations) = result.get("locations").and_then(|v| v.as_array()) { + if let Some(locations) = result.get("locations").and_then(|v| v.as_array()) + { for location in locations { - if let Some(uri) = location.get("physicalLocation") + if let Some(uri) = location + .get("physicalLocation") .and_then(|v| v.get("artifactLocation")) .and_then(|v| v.get("uri")) - .and_then(|v| v.as_str()) { + .and_then(|v| v.as_str()) + { paths.push(uri.to_string()); } } @@ -60,10 +72,10 @@ impl ScanParser for SarifParser { } } } - + Some(ParseResult { paths, scanner }) } - + fn scanner_name(&self) -> &str { "sarif" } diff --git a/src/scanners/parsers/semgrep.rs b/src/scanners/parsers/semgrep.rs index db70bb6..f00548b 100644 --- a/src/scanners/parsers/semgrep.rs +++ b/src/scanners/parsers/semgrep.rs @@ -1,6 +1,6 @@ -use serde_json::Value; +use super::{ParseResult, ScanParser}; use crate::log::debug; -use super::{ScanParser, ParseResult}; +use serde_json::Value; pub struct SemgrepParser; @@ -8,15 +8,15 @@ impl ScanParser for SemgrepParser { fn detect(&self, input: &str) -> bool { input.contains("semgrep.dev") } - + fn parse(&self, input: &str) -> Option { debug("Detected semgrep schema"); - + let data: Value = match serde_json::from_str(input) { Ok(data) => data, Err(_) => return None, }; - + let mut paths = Vec::new(); if let Some(results) = data.get("results").and_then(|v| v.as_array()) { for result in results { @@ -25,13 +25,13 @@ impl ScanParser for SemgrepParser { } } } - + Some(ParseResult { paths, scanner: "semgrep".to_string(), }) } - + fn scanner_name(&self) -> &str { "semgrep" } diff --git a/src/setup_hooks.rs b/src/setup_hooks.rs index c90a78e..44febd8 100644 --- a/src/setup_hooks.rs +++ b/src/setup_hooks.rs @@ -29,11 +29,14 @@ pub fn setup_pre_commit_hook(include_default_scan_types: bool) { }); // Check if pre-commit hook already exists - if std::path::Path::new(&pre_commit_path).exists() { - if !terminal::ask_yes_no("Pre-commit hook already exists. Do you want to overwrite it?", false) { - println!("Skipping pre-commit hook setup."); - return; - } + if std::path::Path::new(&pre_commit_path).exists() + && !terminal::ask_yes_no( + "Pre-commit hook already exists. Do you want to overwrite it?", + false, + ) + { + println!("Skipping pre-commit hook setup."); + return; } // Determine scan types to include @@ -62,10 +65,13 @@ pub fn setup_pre_commit_hook(include_default_scan_types: bool) { // Determine fail-on severity levels to include // Create pre-commit hook content - let hook_content = format!(r#"#!/bin/sh + let hook_content = format!( + r#"#!/bin/sh # Corgea pre-commit hook corgea scan blast --only-uncommitted --fail-on LO --scan-type {} -"#, scan_types.join(",")); +"#, + scan_types.join(",") + ); // Write pre-commit hook std::fs::write(&pre_commit_path, hook_content).unwrap_or_else(|e| { @@ -74,11 +80,14 @@ corgea scan blast --only-uncommitted --fail-on LO --scan-type {} }); #[cfg(unix)] - std::fs::set_permissions(&pre_commit_path, std::os::unix::fs::PermissionsExt::from_mode(0o755)) - .unwrap_or_else(|e| { - eprintln!("Failed to set pre-commit hook permissions: {}", e); - std::process::exit(1); - }); + std::fs::set_permissions( + &pre_commit_path, + std::os::unix::fs::PermissionsExt::from_mode(0o755), + ) + .unwrap_or_else(|e| { + eprintln!("Failed to set pre-commit hook permissions: {}", e); + std::process::exit(1); + }); println!("Successfully installed pre-commit hook!"); } diff --git a/src/targets.rs b/src/targets.rs index 81f2d47..96efe65 100644 --- a/src/targets.rs +++ b/src/targets.rs @@ -1,9 +1,9 @@ +use git2::{Delta, Repository, StatusOptions}; +use globset::{Glob, GlobSetBuilder}; +use ignore::WalkBuilder; use std::collections::HashSet; use std::io::{self, BufRead, Read}; use std::path::{Path, PathBuf}; -use globset::{Glob, GlobSetBuilder}; -use ignore::WalkBuilder; -use git2::{Repository, StatusOptions, Delta}; #[derive(Debug)] pub struct TargetResolutionResult { @@ -66,7 +66,11 @@ pub fn resolve_targets(target_value: &str) -> Result Result Result, Stri } let path = Path::new(segment); - + let full_path = if path.is_absolute() { path.to_path_buf() } else { @@ -140,10 +141,11 @@ fn read_stdin_files(nul_delimited: bool) -> Result, String> { if nul_delimited { let mut buffer = Vec::new(); - stdin.lock().read_to_end(&mut buffer).map_err(|e| { - format!("Failed to read from stdin: {}", e) - })?; - + stdin + .lock() + .read_to_end(&mut buffer) + .map_err(|e| format!("Failed to read from stdin: {}", e))?; + for part in buffer.split(|&b| b == 0) { if part.is_empty() { continue; @@ -216,26 +218,25 @@ fn resolve_git_selector(selector: &str, repo_root: &Path) -> Result } fn get_git_staged_files(repo_root: &Path) -> Result, String> { - let repo = Repository::open(repo_root) - .map_err(|e| format!("Failed to open git repository: {}", e))?; + let repo = + Repository::open(repo_root).map_err(|e| format!("Failed to open git repository: {}", e))?; - let mut index = repo.index() + let mut index = repo + .index() .map_err(|e| format!("Failed to get index: {}", e))?; - let head_tree = repo.head() - .ok() - .and_then(|head| head.peel_to_tree().ok()); + let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); - let index_tree_id = index.write_tree() + let index_tree_id = index + .write_tree() .map_err(|e| format!("Failed to write index tree: {}", e))?; - let index_tree = repo.find_tree(index_tree_id) + let index_tree = repo + .find_tree(index_tree_id) .map_err(|e| format!("Failed to find index tree: {}", e))?; - let diff = repo.diff_tree_to_tree( - head_tree.as_ref(), - Some(&index_tree), - None - ).map_err(|e| format!("Failed to create diff: {}", e))?; + let diff = repo + .diff_tree_to_tree(head_tree.as_ref(), Some(&index_tree), None) + .map_err(|e| format!("Failed to create diff: {}", e))?; let mut files = Vec::new(); diff.foreach( @@ -253,21 +254,23 @@ fn get_git_staged_files(repo_root: &Path) -> Result, String> { None, None, None, - ).map_err(|e| format!("Failed to iterate diff: {}", e))?; + ) + .map_err(|e| format!("Failed to iterate diff: {}", e))?; Ok(files) } fn get_git_untracked_files(repo_root: &Path) -> Result, String> { - let repo = Repository::open(repo_root) - .map_err(|e| format!("Failed to open git repository: {}", e))?; + let repo = + Repository::open(repo_root).map_err(|e| format!("Failed to open git repository: {}", e))?; let mut opts = StatusOptions::new(); opts.include_untracked(true); opts.exclude_submodules(true); opts.include_ignored(false); - let statuses = repo.statuses(Some(&mut opts)) + let statuses = repo + .statuses(Some(&mut opts)) .map_err(|e| format!("Failed to get statuses: {}", e))?; let mut files = Vec::new(); @@ -284,17 +287,14 @@ fn get_git_untracked_files(repo_root: &Path) -> Result, String> { } fn get_git_modified_files(repo_root: &Path) -> Result, String> { - let repo = Repository::open(repo_root) - .map_err(|e| format!("Failed to open git repository: {}", e))?; + let repo = + Repository::open(repo_root).map_err(|e| format!("Failed to open git repository: {}", e))?; - let head_tree = repo.head() - .ok() - .and_then(|head| head.peel_to_tree().ok()); + let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); - let diff = repo.diff_tree_to_workdir( - head_tree.as_ref(), - None - ).map_err(|e| format!("Failed to create diff: {}", e))?; + let diff = repo + .diff_tree_to_workdir(head_tree.as_ref(), None) + .map_err(|e| format!("Failed to create diff: {}", e))?; let mut files = Vec::new(); diff.foreach( @@ -312,14 +312,15 @@ fn get_git_modified_files(repo_root: &Path) -> Result, String> { None, None, None, - ).map_err(|e| format!("Failed to iterate diff: {}", e))?; + ) + .map_err(|e| format!("Failed to iterate diff: {}", e))?; Ok(files) } fn get_git_diff_files(repo_root: &Path, range: &str) -> Result, String> { - let repo = Repository::open(repo_root) - .map_err(|e| format!("Failed to open git repository: {}", e))?; + let repo = + Repository::open(repo_root).map_err(|e| format!("Failed to open git repository: {}", e))?; let parts: Vec<&str> = range.split("...").collect(); let (old_ref, new_ref) = if parts.len() == 2 { @@ -329,23 +330,28 @@ fn get_git_diff_files(repo_root: &Path, range: &str) -> Result, Str if parts.len() == 2 { (parts[0].trim(), parts[1].trim()) } else { - return Err(format!("Invalid diff range format: {}. Expected format: 'old..new' or 'old...new'", range)); + return Err(format!( + "Invalid diff range format: {}. Expected format: 'old..new' or 'old...new'", + range + )); } }; let old_commit = if old_ref.is_empty() { None } else { - Some(repo.revparse_single(old_ref) - .map_err(|e| format!("Failed to resolve reference '{}': {}", old_ref, e))? - .id()) + Some( + repo.revparse_single(old_ref) + .map_err(|e| format!("Failed to resolve reference '{}': {}", old_ref, e))? + .id(), + ) }; let new_commit = if new_ref.is_empty() { repo.head() .map_err(|e| format!("Failed to get HEAD: {}", e))? .target() - .ok_or_else(|| format!("HEAD is not a direct reference"))? + .ok_or_else(|| "HEAD is not a direct reference".to_string())? } else { repo.revparse_single(new_ref) .map_err(|e| format!("Failed to resolve reference '{}': {}", new_ref, e))? @@ -353,24 +359,25 @@ fn get_git_diff_files(repo_root: &Path, range: &str) -> Result, Str }; let old_tree = if let Some(old_id) = old_commit { - Some(repo.find_commit(old_id) - .map_err(|e| format!("Failed to find commit: {}", e))? - .tree() - .map_err(|e| format!("Failed to get tree: {}", e))?) + Some( + repo.find_commit(old_id) + .map_err(|e| format!("Failed to find commit: {}", e))? + .tree() + .map_err(|e| format!("Failed to get tree: {}", e))?, + ) } else { None }; - let new_tree = repo.find_commit(new_commit) + let new_tree = repo + .find_commit(new_commit) .map_err(|e| format!("Failed to find commit: {}", e))? .tree() .map_err(|e| format!("Failed to get tree: {}", e))?; - let diff = repo.diff_tree_to_tree( - old_tree.as_ref(), - Some(&new_tree), - None - ).map_err(|e| format!("Failed to create diff: {}", e))?; + let diff = repo + .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None) + .map_err(|e| format!("Failed to create diff: {}", e))?; let mut files = Vec::new(); diff.foreach( @@ -388,22 +395,21 @@ fn get_git_diff_files(repo_root: &Path, range: &str) -> Result, Str None, None, None, - ).map_err(|e| format!("Failed to iterate diff: {}", e))?; + ) + .map_err(|e| format!("Failed to iterate diff: {}", e))?; Ok(files) } fn resolve_directory(dir: &Path, _repo_root: &Path) -> Result, String> { let mut files = Vec::new(); - - let walker = WalkBuilder::new(dir) - .standard_filters(true) - .build(); + + let walker = WalkBuilder::new(dir).standard_filters(true).build(); for result in walker { let entry = result.map_err(|e| format!("Error walking directory: {}", e))?; let path = entry.path(); - + if path.is_file() { files.push(path.to_path_buf()); } @@ -413,24 +419,23 @@ fn resolve_directory(dir: &Path, _repo_root: &Path) -> Result, Stri } fn resolve_glob(pattern: &str, repo_root: &Path) -> Result, String> { - let glob = Glob::new(pattern) - .map_err(|e| format!("Invalid glob pattern '{}': {}", pattern, e))?; + let glob = + Glob::new(pattern).map_err(|e| format!("Invalid glob pattern '{}': {}", pattern, e))?; let mut glob_builder = GlobSetBuilder::new(); glob_builder.add(glob); - let glob_set = glob_builder.build() + let glob_set = glob_builder + .build() .map_err(|e| format!("Failed to build glob set: {}", e))?; let mut files = Vec::new(); - - let walker = WalkBuilder::new(repo_root) - .standard_filters(true) - .build(); + + let walker = WalkBuilder::new(repo_root).standard_filters(true).build(); for result in walker { let entry = result.map_err(|e| format!("Error walking directory: {}", e))?; let path = entry.path(); - + if path.is_file() { // Get relative path from repo root if let Ok(relative) = path.strip_prefix(repo_root) { @@ -459,23 +464,19 @@ fn normalize_path(path: &Path, _repo_root: &Path) -> Result { } fn find_repo_root() -> Result { - let current_dir = std::env::current_dir() - .map_err(|e| format!("Failed to get current directory: {}", e))?; + let current_dir = + std::env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?; match Repository::discover(¤t_dir) { - Ok(repo) => { - repo.workdir() - .map(|p| p.to_path_buf()) - .or_else(|| repo.path().parent().map(|p| p.to_path_buf())) - .ok_or_else(|| "Failed to determine repository root".to_string()) - } - Err(_) => { - Ok(current_dir) - } + Ok(repo) => repo + .workdir() + .map(|p| p.to_path_buf()) + .or_else(|| repo.path().parent().map(|p| p.to_path_buf())) + .ok_or_else(|| "Failed to determine repository root".to_string()), + Err(_) => Ok(current_dir), } } fn is_git_repo(dir: &Path) -> bool { Repository::discover(dir).is_ok() } - diff --git a/src/utils/api.rs b/src/utils/api.rs index f0e8a59..dfe01ed 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -1,16 +1,19 @@ +use crate::log::debug; use crate::utils; -use serde_json::json; -use std::collections::HashMap; use reqwest::header::HeaderMap; -use serde::{Deserialize, Serialize}; use reqwest::StatusCode; -use std::fs::File; +use reqwest::{ + blocking::multipart, + blocking::multipart::{Form, Part}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use serde_json::Value; +use std::collections::HashMap; use std::error::Error; +use std::fs::File; use std::io::Read; use std::path::Path; -use reqwest::{blocking::multipart, blocking::multipart::{Form, Part}}; -use serde_json::Value; -use crate::log::debug; const CHUNK_SIZE: usize = 50 * 1024 * 1024; // 50 MB const API_BASE: &str = "/api/v1"; @@ -58,7 +61,7 @@ static SHARED_CLIENT: std::sync::LazyLock = debug(&format!("https_proxy detected: {}", https_proxy)); if std::env::var("CORGEA_ACCEPT_CERT").is_ok() { - debug(&format!("Skipping CA cert validation")); + debug("Skipping CA cert validation"); builder = builder.danger_accept_invalid_certs(true); } } @@ -77,15 +80,24 @@ pub struct DebugRequestBuilder { impl HttpClient { pub fn get(&self, url: U) -> DebugRequestBuilder { - DebugRequestBuilder { client: self.inner.clone(), inner: self.inner.get(url) } + DebugRequestBuilder { + client: self.inner.clone(), + inner: self.inner.get(url), + } } pub fn post(&self, url: U) -> DebugRequestBuilder { - DebugRequestBuilder { client: self.inner.clone(), inner: self.inner.post(url) } + DebugRequestBuilder { + client: self.inner.clone(), + inner: self.inner.post(url), + } } pub fn patch(&self, url: U) -> DebugRequestBuilder { - DebugRequestBuilder { client: self.inner.clone(), inner: self.inner.patch(url) } + DebugRequestBuilder { + client: self.inner.clone(), + inner: self.inner.patch(url), + } } } @@ -97,19 +109,31 @@ impl DebugRequestBuilder { reqwest::header::HeaderValue: TryFrom, >::Error: Into, { - Self { inner: self.inner.header(key, value), client: self.client } + Self { + inner: self.inner.header(key, value), + client: self.client, + } } pub fn query(self, query: &T) -> Self { - Self { inner: self.inner.query(query), client: self.client } + Self { + inner: self.inner.query(query), + client: self.client, + } } pub fn multipart(self, form: reqwest::blocking::multipart::Form) -> Self { - Self { inner: self.inner.multipart(form), client: self.client } + Self { + inner: self.inner.multipart(form), + client: self.client, + } } pub fn body>(self, body: T) -> Self { - Self { inner: self.inner.body(body), client: self.client } + Self { + inner: self.inner.body(body), + client: self.client, + } } pub fn send(self) -> reqwest::Result { @@ -127,7 +151,10 @@ impl DebugRequestBuilder { debug(&format!("→ {} {}", request.method(), request.url())); debug(&format!(" Request headers: {:?}", request.headers())); match COOKIE_JAR.cookies(request.url()) { - Some(cookies) => debug(&format!(" Cookie: {}", cookies.to_str().unwrap_or(""))), + Some(cookies) => debug(&format!( + " Cookie: {}", + cookies.to_str().unwrap_or("") + )), None => debug(" Cookie: (none in jar for this URL)"), } @@ -141,7 +168,9 @@ impl DebugRequestBuilder { } pub fn http_client() -> HttpClient { - HttpClient { inner: SHARED_CLIENT.clone() } + HttpClient { + inner: SHARED_CLIENT.clone(), + } } fn check_for_warnings(headers: &HeaderMap, status: StatusCode) { @@ -171,68 +200,81 @@ pub fn upload_zip( project_name: &str, repo_info: Option, scan_type: Option, - policy: Option + policy: Option, ) -> Result> { let client = http_client(); let file_size = std::fs::metadata(file_path)?.len(); - let file_name = Path::new(file_path) - .file_name() - .unwrap() - .to_str() - .unwrap(); + let file_name = Path::new(file_path).file_name().unwrap().to_str().unwrap(); let json_object = json!({ "file_name": file_name, "file_size": file_size }); let form = reqwest::blocking::multipart::Form::new() - .part("files", reqwest::blocking::multipart::Part::bytes(Vec::new()) - .file_name(file_name.to_string())) + .part( + "files", + reqwest::blocking::multipart::Part::bytes(Vec::new()).file_name(file_name.to_string()), + ) .text("json", json_object.to_string()); let response_object = client .post(format!("{}{}/start-scan", url, API_BASE)) - .query(&[ - ("scan_type", "blast"), - ]) + .query(&[("scan_type", "blast")]) .multipart(form) .send(); let response_object = match response_object { Ok(response) => { check_for_warnings(response.headers(), response.status()); response - }, - Err(err) => return Err(format!("Network error: Unable to reach the server. Please try again later. Error: {}", err).into()), + } + Err(err) => { + return Err(format!( + "Network error: Unable to reach the server. Please try again later. Error: {}", + err + ) + .into()) + } }; let response_status = response_object.status(); let response_text = response_object.text()?; - + if response_status != StatusCode::OK { - debug(&format!("Initial scan request failed with status: {}. Response body: {}", response_status, response_text)); - + debug(&format!( + "Initial scan request failed with status: {}. Response body: {}", + response_status, response_text + )); + if response_status == StatusCode::BAD_REQUEST { - if let Ok(error_response) = serde_json::from_str::>(&response_text) { + if let Ok(error_response) = + serde_json::from_str::>(&response_text) + { if let Some(message) = error_response.get("message").and_then(Value::as_str) { return Err(format!("Request failed: {}", message).into()); } } return Err(format!("Request failed (400): {}", response_text).into()); } - + return Err("Error getting server response, Please try again later.".into()); } - + let response: HashMap = match serde_json::from_str(&response_text) { Ok(json) => json, Err(_) => { - debug(&format!("Failed to parse initial scan response as JSON. Response body: {}", response_text)); + debug(&format!( + "Failed to parse initial scan response as JSON. Response body: {}", + response_text + )); return Err("Error getting server response, Please try again later.".into()); - }, + } }; let transfer_id = match response["transfer_id"].as_str() { Some(transfer_id) => transfer_id, - None => return Err("Failed to retrieve transfer ID. Please check the request parameters and try again.".into()), + None => return Err( + "Failed to retrieve transfer ID. Please check the request parameters and try again." + .into(), + ), }; let mut file = File::open(file_path)?; let mut buffer = vec![0; CHUNK_SIZE]; @@ -247,14 +289,17 @@ pub fn upload_zip( let chunk = &buffer[..bytes_read]; let mut form = Form::new() - .part( - "chunk_data", - Part::bytes(chunk.to_vec()) - .file_name(file_name.to_string()) - .mime_str("application/octet-stream")?, - ) - .part("project_name", multipart::Part::text(project_name.to_string())) - .part("file_size", multipart::Part::text(file_size.to_string())); + .part( + "chunk_data", + Part::bytes(chunk.to_vec()) + .file_name(file_name.to_string()) + .mime_str("application/octet-stream")?, + ) + .part( + "project_name", + multipart::Part::text(project_name.to_string()), + ) + .part("file_size", multipart::Part::text(file_size.to_string())); if let Some(ref info) = repo_info { if let Some(branch) = &info.branch { form = form.part("branch", multipart::Part::text(branch.to_string())); @@ -279,58 +324,69 @@ pub fn upload_zip( } let response = match client - .patch(format!("{}{}/start-scan/{}/", url, API_BASE, transfer_id)) - .header("Upload-Offset", offset.to_string()) - .header("Upload-Length", file_size.to_string()) - .header("Upload-Name", file_name) - .query(&[ - ("scan_type", "blast") - ]) - .multipart(form) - .send() { + .patch(format!("{}{}/start-scan/{}/", url, API_BASE, transfer_id)) + .header("Upload-Offset", offset.to_string()) + .header("Upload-Length", file_size.to_string()) + .header("Upload-Name", file_name) + .query(&[("scan_type", "blast")]) + .multipart(form) + .send() + { Ok(response) => { check_for_warnings(response.headers(), response.status()); response - }, + } Err(e) => { return Err(format!("Failed to send request: {}", e).into()); } }; if !response.status().is_success() { let status_code = response.status(); - let response_text = response.text().unwrap_or_else(|_| "Unable to read response body".to_string()); - debug(&format!("Chunk upload failed with status: {}. Response body: {}", status_code, response_text)); - + let response_text = response + .text() + .unwrap_or_else(|_| "Unable to read response body".to_string()); + debug(&format!( + "Chunk upload failed with status: {}. Response body: {}", + status_code, response_text + )); + if status_code.is_client_error() && response_text.contains("Invalid policy ids") { - return Err("Invalid policy ids passed. Please check the policy ids and try again.".into()); + return Err( + "Invalid policy ids passed. Please check the policy ids and try again.".into(), + ); } - + if status_code == StatusCode::BAD_REQUEST { - if let Ok(error_response) = serde_json::from_str::>(&response_text) { + if let Ok(error_response) = + serde_json::from_str::>(&response_text) + { if let Some(message) = error_response.get("message").and_then(Value::as_str) { return Err(format!("Upload failed: {}", message).into()); } } return Err(format!("Upload failed (400): {}", response_text).into()); } - - return Err(format!("Failed to upload file: {}", status_code).into()); + return Err(format!("Failed to upload file: {}", status_code).into()); } utils::terminal::show_progress_bar(offset as f32 / file_size as f32); offset += bytes_read as u64; if bytes_read < CHUNK_SIZE { utils::terminal::show_progress_bar(1.0); - print!("\n"); + println!(); let body: HashMap = response.json()?; if let Some(scan_id_value) = body.get("scan_id") { let scan_id = scan_id_value.as_str().unwrap().to_string(); let project_id = body.get("project_id").and_then(|v| { - v.as_str().map(|s| s.to_string()) + v.as_str() + .map(|s| s.to_string()) .or_else(|| v.as_i64().map(|n| n.to_string())) }); - return Ok(UploadZipResult { scan_id, project_id }); + return Ok(UploadZipResult { + scan_id, + project_id, + }); } else { return Err("Failed to get scan_id from response".into()); } @@ -340,14 +396,24 @@ pub fn upload_zip( Err("Failed to upload file".into()) } -pub fn get_all_issues(url: &str, project: &str, scan_id: Option) -> Result, Box> { +pub fn get_all_issues( + url: &str, + project: &str, + scan_id: Option, +) -> Result, Box> { let mut all_issues = Vec::new(); let mut current_page: u32 = 1; loop { - let response = match get_scan_issues(url, project, Some(current_page as u16), Some(30), scan_id.clone()) { + let response = match get_scan_issues( + url, + project, + Some(current_page as u16), + Some(30), + scan_id.clone(), + ) { Ok(response) => response, - Err(e) => return Err(format!("Failed to get scan issues: {}", e).into()) + Err(e) => return Err(format!("Failed to get scan issues: {}", e).into()), }; if let Some(mut issues) = response.issues { @@ -374,19 +440,14 @@ pub fn get_scan_issues( project: &str, page: Option, page_size: Option, - scan_id: Option -) -> Result> { + scan_id: Option, +) -> Result> { let mut seperator = "?"; let mut url = match scan_id { Some(scan_id) => format!("{}{}/scan/{}/issues", url, API_BASE, scan_id), None => { seperator = "&"; - format!( - "{}{}/issues?project={}", - url, - API_BASE, - project - ) + format!("{}{}/issues?project={}", url, API_BASE, project) } }; if let Some(p) = page { @@ -405,14 +466,18 @@ pub fn get_scan_issues( Ok(res) => { check_for_warnings(res.headers(), res.status()); res - }, + } Err(e) => return Err(format!("Failed to send request: {}", e).into()), }; let response_text = response.text()?; - let project_issues_response: ProjectIssuesResponse = serde_json::from_str(&response_text).map_err(|e| { - debug(&format!("Failed to parse response: {}. Response body: {}", e, response_text)); - format!("Failed to parse response: {}", e) - })?; + let project_issues_response: ProjectIssuesResponse = serde_json::from_str(&response_text) + .map_err(|e| { + debug(&format!( + "Failed to parse response: {}. Response body: {}", + e, response_text + )); + format!("Failed to parse response: {}", e) + })?; if project_issues_response.status == "ok" { Ok(project_issues_response) @@ -423,7 +488,7 @@ pub fn get_scan_issues( } } -pub fn get_scan(url: &str, scan_id: &str) -> Result> { +pub fn get_scan(url: &str, scan_id: &str) -> Result> { let url = format!("{}{}/scan/{}", url, API_BASE, scan_id); let client = http_client(); @@ -438,16 +503,27 @@ pub fn get_scan(url: &str, scan_id: &str) -> Result) -> Result> { +pub fn get_scan_report( + url: &str, + scan_id: &str, + format: Option<&str>, +) -> Result> { let url = if let Some(fmt) = format { format!("{}{}/scan/{}/report?format={}", url, API_BASE, scan_id, fmt) } else { @@ -468,43 +544,43 @@ pub fn get_scan_report(url: &str, scan_id: &str, format: Option<&str>) -> Result if response.status().is_success() { Ok(response.text()?) } else { - Err(format!("Error: Unable to fetch scan report. Status code: {}", response.status()).into()) + Err(format!( + "Error: Unable to fetch scan report. Status code: {}", + response.status() + ) + .into()) } } pub fn get_issue(url: &str, issue: &str) -> Result> { - let url = format!( - "{}{}/issue/{}", - url, - API_BASE, - issue, - ); + let url = format!("{}{}/issue/{}", url, API_BASE, issue,); let client = http_client(); debug(&format!("Sending request to URL: {}", url)); let response = match client.get(&url).send() { Ok(res) => { check_for_warnings(res.headers(), res.status()); res - }, + } Err(e) => return Err(format!("Failed to send request: {}", e).into()), }; let response_text = response.text()?; - return match serde_json::from_str::(&response_text) { + match serde_json::from_str::(&response_text) { Ok(body) => Ok(body), Err(e) => { - debug(&format!("Failed to parse response: {}. Response body: {}", e, response_text)); + debug(&format!( + "Failed to parse response: {}. Response body: {}", + e, response_text + )); Err(format!("Failed to parse response: {}", e).into()) - }, - }; + } + } } - - pub fn query_scan_list( url: &str, project: Option<&str>, page: Option, - page_size: Option + page_size: Option, ) -> Result> { let url = format!("{}{}/scans", url, API_BASE); let page = page.unwrap_or(1); @@ -518,60 +594,57 @@ pub fn query_scan_list( query_params.push(("project", project.to_string())); } - let client = http_client(); debug(&format!("Sending request to URL: {}", url)); - let response = match client - .get(url) - .query(&query_params) - .send() { - Ok(res) => { - check_for_warnings(res.headers(), res.status()); - res - }, - Err(e) => return Err(format!("API request failed: {}", e).into()), - }; - if response.status().is_success() { - let response_text = response.text()?; - let api_response: ScansResponse = serde_json::from_str(&response_text).map_err(|e| { - debug(&format!("Failed to parse response: {}. Response body: {}", e, response_text)); - format!("Failed to parse response: {}", e) - })?; - Ok(api_response) - } else { - Err(format!( - "API request failed with status: {}", - response.status() - ).into()) + let response = match client.get(url).query(&query_params).send() { + Ok(res) => { + check_for_warnings(res.headers(), res.status()); + res } + Err(e) => return Err(format!("API request failed: {}", e).into()), + }; + if response.status().is_success() { + let response_text = response.text()?; + let api_response: ScansResponse = serde_json::from_str(&response_text).map_err(|e| { + debug(&format!( + "Failed to parse response: {}. Response body: {}", + e, response_text + )); + format!("Failed to parse response: {}", e) + })?; + Ok(api_response) + } else { + Err(format!("API request failed with status: {}", response.status()).into()) + } } - pub fn exchange_code_for_token( base_url: &str, code: &str, ) -> Result> { let client = reqwest::blocking::Client::new(); let exchange_url = format!("{}{}/authorize", base_url, API_BASE); - + let response = client .get(&exchange_url) .header("CORGEA-SOURCE", get_source()) .query(&[("code", code)]) .send()?; - + if response.status().is_success() { let response_json: HashMap = response.json()?; - + if let Some(user_token) = response_json.get("user_token") { if let Some(user_token_str) = user_token.as_str() { return Ok(user_token_str.to_string()); } } - + Err("User token not found in response".into()) } else { - let error_text = response.text().unwrap_or_else(|_| "Unknown error".to_string()); + let error_text = response + .text() + .unwrap_or_else(|_| "Unknown error".to_string()); Err(format!("Failed to exchange code for user token: {}", error_text).into()) } } @@ -581,9 +654,7 @@ pub fn verify_token(corgea_url: &str) -> Result> { let client = http_client(); debug(&format!("Sending request to URL: {}", url)); - let response = client - .get(&url) - .send()?; + let response = client.get(&url).send()?; check_for_warnings(response.headers(), response.status()); @@ -592,8 +663,11 @@ pub fn verify_token(corgea_url: &str) -> Result> { let body: HashMap = match serde_json::from_str(&body_text) { Ok(json) => json, Err(e) => { - debug(&format!("Failed to parse response as JSON: {}. Response body: {}", e, body_text)); - return Err(format!("Failed to parse response").into()); + debug(&format!( + "Failed to parse response as JSON: {}. Response body: {}", + e, body_text + )); + return Err("Failed to parse response".to_string().into()); } }; @@ -606,9 +680,12 @@ pub fn verify_token(corgea_url: &str) -> Result> { pub fn check_blocking_rules( url: &str, sast_scan_id: &str, - page: Option + page: Option, ) -> Result> { - let url = format!("{}{}/scan/{}/check_blocking_rules", url, API_BASE, sast_scan_id); + let url = format!( + "{}{}/scan/{}/check_blocking_rules", + url, API_BASE, sast_scan_id + ); let page = page.unwrap_or(1); let query_params = vec![("page", page.to_string())]; @@ -616,43 +693,40 @@ pub fn check_blocking_rules( debug(&format!("Sending request to URL: {}", url)); debug(&format!("Query params: {:?}", query_params)); - let response = match client - .get(url) - .query(&query_params) - .send() { - Ok(res) => { - check_for_warnings(res.headers(), res.status()); - debug(&format!("Response status: {}", res.status())); - debug(&format!("Response headers: {:?}", res.headers())); - res - }, - Err(e) => return Err(format!("API request failed: {}", e).into()), - }; + let response = match client.get(url).query(&query_params).send() { + Ok(res) => { + check_for_warnings(res.headers(), res.status()); + debug(&format!("Response status: {}", res.status())); + debug(&format!("Response headers: {:?}", res.headers())); + res + } + Err(e) => return Err(format!("API request failed: {}", e).into()), + }; if response.status().is_success() { let response_text = response.text()?; - let api_response: BlockingRuleResponse = serde_json::from_str(&response_text).map_err(|e| { - debug(&format!("Failed to parse response: {}. Response body: {}", e, response_text)); - format!("Failed to parse response: {}", e) - })?; + let api_response: BlockingRuleResponse = + serde_json::from_str(&response_text).map_err(|e| { + debug(&format!( + "Failed to parse response: {}. Response body: {}", + e, response_text + )); + format!("Failed to parse response: {}", e) + })?; Ok(api_response) } else { let status = response.status(); let response_text = response.text()?; debug(&format!("Response body: {}", response_text)); - Err(format!( - "API request failed with status: {}", - status - ).into()) + Err(format!("API request failed with status: {}", status).into()) } } - pub fn get_sca_issues( url: &str, page: Option, page_size: Option, - scan_id: Option + scan_id: Option, ) -> Result> { let client = http_client(); let mut query_params = vec![]; @@ -672,10 +746,7 @@ pub fn get_sca_issues( debug(&format!("Sending request to URL: {}", endpoint)); debug(&format!("Query params: {:?}", query_params)); - let response = client - .get(&endpoint) - .query(&query_params) - .send(); + let response = client.get(&endpoint).query(&query_params).send(); let response = match response { Ok(response) => { @@ -683,14 +754,23 @@ pub fn get_sca_issues( debug(&format!("Response status: {}", response.status())); debug(&format!("Response headers: {:?}", response.headers())); response - }, - Err(err) => return Err(format!("Network error: Unable to reach the server. Please try again later. Error: {}", err).into()), + } + Err(err) => { + return Err(format!( + "Network error: Unable to reach the server. Please try again later. Error: {}", + err + ) + .into()) + } }; let status = response.status(); if !status.is_success() { if status == StatusCode::NOT_FOUND { - return Err("SCA issues not found. Please check the scan ID or ensure the scan has SCA issues.".into()); + return Err( + "SCA issues not found. Please check the scan ID or ensure the scan has SCA issues." + .into(), + ); } return Err(format!("Request failed with status: {}", status).into()); } @@ -699,9 +779,12 @@ pub fn get_sca_issues( let response_data: SCAIssuesResponse = match serde_json::from_str(&response_text) { Ok(json) => json, Err(e) => { - debug(&format!("Failed to parse response: {}. Response body: {}", e, response_text)); - return Err("Error parsing server response. Please try again later.".into()) - }, + debug(&format!( + "Failed to parse response: {}. Response body: {}", + e, response_text + )); + return Err("Error parsing server response. Please try again later.".into()); + } }; Ok(response_data) @@ -710,16 +793,17 @@ pub fn get_sca_issues( pub fn get_all_sca_issues( url: &str, _project: &str, - scan_id: Option + scan_id: Option, ) -> Result, Box> { let mut all_issues = Vec::new(); let mut current_page: u32 = 1; loop { - let response = match get_sca_issues(url, Some(current_page as u16), Some(30), scan_id.clone()) { - Ok(response) => response, - Err(e) => return Err(format!("Failed to get SCA issues: {}", e).into()) - }; + let response = + match get_sca_issues(url, Some(current_page as u16), Some(30), scan_id.clone()) { + Ok(response) => response, + Err(e) => return Err(format!("Failed to get SCA issues: {}", e).into()), + }; if response.issues.is_empty() { break; @@ -737,7 +821,7 @@ pub fn get_all_sca_issues( } #[derive(Deserialize, Serialize, Debug)] -pub struct ScanResponse { +pub struct ScanResponse { pub id: String, pub project: String, pub repo: Option, @@ -753,10 +837,9 @@ pub struct ProjectIssuesResponse { pub issues: Option>, pub page: Option, pub total_pages: Option, - pub total_issues: Option + pub total_issues: Option, } - #[derive(Serialize, Deserialize, Debug)] pub struct ScansResponse { pub status: String, @@ -765,7 +848,6 @@ pub struct ScansResponse { pub scans: Option>, } - #[derive(Serialize, Deserialize, Debug)] pub struct FullIssueResponse { pub status: String, @@ -802,7 +884,6 @@ pub struct IssueWithBlockingRules { pub blocking_rules: Option>, } - #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Classification { pub id: String, @@ -877,7 +958,7 @@ pub struct BlockingRuleResponse { #[derive(Deserialize, Debug, Clone)] pub struct BlockingIssue { pub id: String, - pub triggered_by_rules: Vec + pub triggered_by_rules: Vec, } #[derive(Deserialize, Serialize, Debug)] @@ -913,3 +994,80 @@ pub struct SCAIssuesResponse { pub total_pages: u32, pub total_issues: u32, } + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::header::{HeaderMap, HeaderValue}; + + #[test] + fn is_jwt_accepts_three_dot_separated_non_empty_parts() { + assert!(is_jwt("aaa.bbb.ccc")); + assert!(is_jwt("header.payload.signature")); + } + + #[test] + fn is_jwt_rejects_wrong_part_count() { + assert!(!is_jwt("aaa.bbb")); + assert!(!is_jwt("aaa.bbb.ccc.ddd")); + assert!(!is_jwt("plainstring")); + assert!(!is_jwt("")); + } + + #[test] + fn is_jwt_rejects_when_any_part_is_empty() { + assert!(!is_jwt("aaa..ccc")); + assert!(!is_jwt(".bbb.ccc")); + assert!(!is_jwt("aaa.bbb.")); + } + + #[test] + fn auth_headers_uses_bearer_for_jwt_tokens() { + let headers = auth_headers("aaa.bbb.ccc"); + + assert_eq!( + headers.get("Authorization").map(|v| v.to_str().unwrap()), + Some("Bearer aaa.bbb.ccc") + ); + assert!(headers.get("CORGEA-TOKEN").is_none()); + assert!(headers.get("CORGEA-SOURCE").is_some()); + } + + #[test] + fn auth_headers_uses_corgea_token_header_for_opaque_tokens() { + let headers = auth_headers("opaque-token-xyz"); + + assert_eq!( + headers.get("CORGEA-TOKEN").map(|v| v.to_str().unwrap()), + Some("opaque-token-xyz") + ); + assert!(headers.get("Authorization").is_none()); + assert!(headers.get("CORGEA-SOURCE").is_some()); + } + + #[test] + fn check_for_warnings_is_noop_when_no_warning_header_and_status_ok() { + let headers = HeaderMap::new(); + check_for_warnings(&headers, StatusCode::OK); + } + + #[test] + fn check_for_warnings_is_noop_for_non_299_codes() { + let mut headers = HeaderMap::new(); + headers.insert( + "warning", + HeaderValue::from_static("199 - \"misc warning\""), + ); + check_for_warnings(&headers, StatusCode::OK); + } + + #[test] + fn check_for_warnings_tolerates_multiple_comma_separated_warnings() { + let mut headers = HeaderMap::new(); + headers.insert( + "warning", + HeaderValue::from_static("199 host \"first\", 299 host \"deprecated\""), + ); + check_for_warnings(&headers, StatusCode::OK); + } +} diff --git a/src/utils/generic.rs b/src/utils/generic.rs index 627ddda..7cfad56 100644 --- a/src/utils/generic.rs +++ b/src/utils/generic.rs @@ -1,12 +1,12 @@ +use crate::utils::terminal::{set_text_color, TerminalColor}; +use git2::Repository; +use globset::{Glob, GlobSetBuilder}; +use ignore::WalkBuilder; +use std::env; +use std::fs::{self, File}; use std::io; use std::path::{Path, PathBuf}; use zip::{write::FileOptions, ZipWriter}; -use ignore::WalkBuilder; -use globset::{GlobSetBuilder, Glob}; -use std::fs::{self, File}; -use std::env; -use git2::Repository; -use crate::utils::terminal::{set_text_color, TerminalColor}; // Global exclude globs used across multiple functions const DEFAULT_EXCLUDE_GLOBS: &[&str] = &[ @@ -32,7 +32,7 @@ const DEFAULT_EXCLUDE_GLOBS: &[&str] = &[ ]; /// Create a zip file from a target specification or full repository scan. -/// +/// /// - If `target` is `None`, performs a full repository scan (equivalent to scanning all files). /// - If `target` is `Some(target_str)`, resolves the target using the targets module and creates zip from those files. /// The target string can be a comma-separated list of files, directories, globs, or git selectors. @@ -53,8 +53,9 @@ pub fn create_zip_from_target>( let current_dir = env::current_dir()?; let result = crate::targets::resolve_targets(target_str) .map_err(|e| format!("Failed to resolve targets: {}", e))?; - - result.files + + result + .files .iter() .filter_map(|file| { if !file.exists() || !file.is_file() { @@ -62,17 +63,13 @@ pub fn create_zip_from_target>( } match file.strip_prefix(¤t_dir) { Ok(relative) => Some((file.clone(), relative.to_path_buf())), - Err(_) => { - Some((file.clone(), file.clone())) - } + Err(_) => Some((file.clone(), file.clone())), } }) .collect() } else { let directory = Path::new("."); - let walker = WalkBuilder::new(directory) - .standard_filters(true) - .build(); + let walker = WalkBuilder::new(directory).standard_filters(true).build(); let mut files = Vec::new(); for result in walker { @@ -99,7 +96,7 @@ pub fn create_zip_from_target>( for (path, relative_path) in files_to_zip { let is_excluded = glob_set.is_match(&path); - + if (path.is_file() || path.is_dir()) && !is_excluded { if path.is_file() { zip.start_file(relative_path.to_string_lossy(), options)?; @@ -152,13 +149,12 @@ pub fn create_path_if_not_exists>(path: P) -> io::Result<()> { Ok(()) } - pub fn is_git_repo(dir: &str) -> Result { let git_path = Path::new(dir).join(".git"); if git_path.exists() { return Ok(true); } - + // Fall back to the more expensive discover method for cases like: // - We're in a subdirectory of a git repo // - .git is a file (worktrees, submodules) @@ -183,9 +179,10 @@ pub fn delete_directory>(path: P) -> io::Result<()> { } pub fn get_current_working_directory() -> Option { - env::current_dir() - .ok() - .and_then(|path| path.file_name().map(|name| name.to_string_lossy().to_string())) + env::current_dir().ok().and_then(|path| { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + }) } /// Determine the project name with fallback logic: @@ -227,25 +224,25 @@ fn extract_repo_name_from_url(url: &str) -> Option { // - git@github.com:user/repo.git // - https://github.com/user/repo // - git@github.com:user/repo - + let url = url.trim(); - + let url = url.strip_suffix(".git").unwrap_or(url); - - if let Some(name) = url.split('/').last() { + + if let Some(name) = url.split('/').next_back() { let name = name.trim(); if !name.is_empty() { return Some(name.to_string()); } } - - if let Some(name) = url.split(':').last() { + + if let Some(name) = url.split(':').next_back() { let name = name.trim(); if !name.is_empty() { return Some(name.to_string()); } } - + None } @@ -271,12 +268,23 @@ pub fn get_repo_info(dir: &str) -> Result, git2::Error> { }); // Get the latest commit SHA - let sha = repo.head().ok().and_then(|head| head.peel_to_commit().ok().map(|commit| commit.id().to_string())); + let sha = repo.head().ok().and_then(|head| { + head.peel_to_commit() + .ok() + .map(|commit| commit.id().to_string()) + }); // Get the remote URL (assuming "origin") - let repo_url = repo.find_remote("origin").ok().and_then(|remote| remote.url().map(|url| url.to_string())); + let repo_url = repo + .find_remote("origin") + .ok() + .and_then(|remote| remote.url().map(|url| url.to_string())); - Ok(Some(RepoInfo { branch, repo_url, sha })) + Ok(Some(RepoInfo { + branch, + repo_url, + sha, + })) } pub fn get_status(status: &str) -> &str { @@ -300,4 +308,3 @@ pub struct RepoInfo { pub repo_url: Option, pub sha: Option, } - diff --git a/src/utils/terminal.rs b/src/utils/terminal.rs index 4c726eb..1bb4c4c 100644 --- a/src/utils/terminal.rs +++ b/src/utils/terminal.rs @@ -1,11 +1,11 @@ -use std::io::{self, Write}; -use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; -use std::{thread, time}; -use std::sync::{Arc, Mutex}; use crate::utils; use regex::Regex; +use std::io::{self, Write}; +use std::sync::{Arc, Mutex}; +use std::{thread, time}; +use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; -pub fn show_progress_bar(progress: f32) -> () { +pub fn show_progress_bar(progress: f32) { let total_bar_length = 50; if progress == -1.0 { print!("\r{}", " ".repeat(50)); @@ -27,17 +27,28 @@ pub fn show_progress_bar(progress: f32) -> () { } pub fn show_loading_message(message: &str, stop_signal: Arc>) { - let spinner = vec!["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]; - let spinner_colors = vec![Color::Cyan, Color::Magenta, Color::Yellow, Color::Green]; + let spinner = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]; + let spinner_colors = [Color::Cyan, Color::Magenta, Color::Yellow, Color::Green]; let start_time = time::Instant::now(); let mut i = 0; let mut stdout = StandardStream::stdout(ColorChoice::Always); print!("{} ", message); io::stdout().flush().unwrap(); loop { - stdout.set_color(ColorSpec::new().set_fg(Some(spinner_colors[i % spinner_colors.len()])).set_bg(Some(Color::Black))).unwrap(); + stdout + .set_color( + ColorSpec::new() + .set_fg(Some(spinner_colors[i % spinner_colors.len()])) + .set_bg(Some(Color::Black)), + ) + .unwrap(); let message = message.replace("[T]", &format!("{:.0}", start_time.elapsed().as_secs())); - print!("\r[{}] {}{}", spinner[i % spinner.len()], message, set_text_color("", TerminalColor::Reset)); + print!( + "\r[{}] {}{}", + spinner[i % spinner.len()], + message, + set_text_color("", TerminalColor::Reset) + ); io::stdout().flush().unwrap(); // Sleep for a bit before updating the spinner thread::sleep(time::Duration::from_millis(100)); @@ -53,8 +64,6 @@ pub fn show_loading_message(message: &str, stop_signal: Arc>) { stdout.reset().unwrap(); } - - pub fn set_text_color(txt: &str, color: TerminalColor) -> String { let color_code = match color { TerminalColor::Red => "\x1b[31m", @@ -63,7 +72,7 @@ pub fn set_text_color(txt: &str, color: TerminalColor) -> String { TerminalColor::Yellow => "\x1b[33m", TerminalColor::Reset => "\x1b[0m", }; - return format!("{}{}{}", color_code, txt, "\x1b[0m"); + format!("{}{}{}", color_code, txt, "\x1b[0m") } pub fn show_welcome_message() { @@ -79,7 +88,7 @@ pub fn show_welcome_message() { "#; println!("{}", set_text_color(dog_art, TerminalColor::Green)); -} +} pub fn format_code(code: &str) -> String { let mut formatted_code = String::new(); @@ -89,7 +98,13 @@ pub fn format_code(code: &str) -> String { for capture in regex.captures_iter(code) { if let Some(matched) = capture.get(1) { formatted_code.push_str(&code[last_end..capture.get(0).unwrap().start()]); - formatted_code.push_str(&format!("`{}`", utils::terminal::set_text_color(matched.as_str(), utils::terminal::TerminalColor::Green))); + formatted_code.push_str(&format!( + "`{}`", + utils::terminal::set_text_color( + matched.as_str(), + utils::terminal::TerminalColor::Green + ) + )); last_end = capture.get(0).unwrap().end(); } } @@ -113,9 +128,9 @@ pub fn format_diff(diff: &str) -> String { format!("{}\n", set_text_color(line, TerminalColor::Green)) } else if line.starts_with("@@") { let formatted_text = regex.replace_all(line, |caps: ®ex::Captures| { - set_text_color(&caps[0], TerminalColor::Blue) + set_text_color(&caps[0], TerminalColor::Blue) }); - format!("{}\n", formatted_text) + format!("{}\n", formatted_text) } else if line.starts_with("-") { format!("{}\n", set_text_color(line, TerminalColor::Red)) } else if line.starts_with("+") { @@ -135,7 +150,11 @@ pub fn clear_line(length: usize) { } pub fn clear_previous_line() { - print!("\r{}{}", utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset), " ".repeat(100)); + print!( + "\r{}{}", + utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset), + " ".repeat(100) + ); } pub fn print_with_pagination(str: &str) { @@ -143,7 +162,7 @@ pub fn print_with_pagination(str: &str) { let mut lines = str.lines(); let mut buffer = String::new(); let stdin = io::stdin(); - let message ="-- More -- (Press Enter to continue, Ctrl+C to exit)"; + let message = "-- More -- (Press Enter to continue, Ctrl+C to exit)"; loop { clear_line(message.len()); @@ -154,7 +173,6 @@ pub fn print_with_pagination(str: &str) { clear_line(message.len()); return; } - } print!("{}", message); @@ -163,7 +181,6 @@ pub fn print_with_pagination(str: &str) { buffer.clear(); stdin.read_line(&mut buffer).unwrap(); - print!("\x1B[2K\x1B[1A"); stdout.flush().unwrap(); } @@ -182,30 +199,44 @@ pub fn ask_yes_no(question: &str, should_default: bool) -> bool { loop { print!("{} (y/n): ", question); io::stdout().flush().unwrap(); - + let mut input = String::new(); io::stdin().read_line(&mut input).unwrap(); - + match input.trim().to_lowercase().as_str() { "y" | "yes" => return true, "n" | "no" => return false, - _ => if should_default { - return true; - } else { - println!("Please answer with yes/y or no/n"); + _ => { + if should_default { + return true; + } else { + println!("Please answer with yes/y or no/n"); + } } } } } pub fn print_table(table: Vec>, page: Option, total_pages: Option) { - let columns = table.iter().enumerate().fold(vec![vec![]; table[0].len()], |mut acc, (_i, row)| { - for (j, cell) in row.iter().enumerate() { - acc[j].push(cell.clone()); - } - acc - }); - let column_lengths = columns.iter().map(|col| col.iter().map(|cell| cell.len()).max_by(|a, b| a.cmp(b)).unwrap_or(0)).collect::>(); + let columns = + table + .iter() + .enumerate() + .fold(vec![vec![]; table[0].len()], |mut acc, (_i, row)| { + for (j, cell) in row.iter().enumerate() { + acc[j].push(cell.clone()); + } + acc + }); + let column_lengths = columns + .iter() + .map(|col| { + col.iter() + .map(|cell| cell.len()) + .max_by(|a, b| a.cmp(b)) + .unwrap_or(0) + }) + .collect::>(); for (j, row) in table.iter().enumerate() { for (i, cell) in row.iter().enumerate() { print!("{:>, page: Option, total_pages: Opti } } - pub enum TerminalColor { Reset, Red, Green, Blue, Yellow, -} \ No newline at end of file +} diff --git a/src/wait.rs b/src/wait.rs index c0ce3e7..8a7cccc 100644 --- a/src/wait.rs +++ b/src/wait.rs @@ -1,7 +1,6 @@ -use crate::utils; use crate::config::Config; use crate::scanners::blast; - +use crate::utils; pub fn run(config: &Config, scan_id: Option, project_id: Option) { let project_name = match utils::generic::get_current_working_directory() { @@ -12,7 +11,8 @@ pub fn run(config: &Config, scan_id: Option, project_id: Option) } }; - let scans_result = utils::api::query_scan_list(&config.get_url(), Some(&project_name), Some(1), None); + let scans_result = + utils::api::query_scan_list(&config.get_url(), Some(&project_name), Some(1), None); let scans: Vec = match scans_result { Ok(result) => result.scans.unwrap_or_default(), Err(e) => { @@ -23,7 +23,7 @@ pub fn run(config: &Config, scan_id: Option, project_id: Option) Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli - Error details: {}", + Error details: {}", e ); std::process::exit(1); @@ -41,21 +41,24 @@ pub fn run(config: &Config, scan_id: Option, project_id: Option) } }; (scan_id.to_string(), processed) - }, - None => { - match scans.get(0) { - Some(scan) => (scan.id.clone(), scan.status == "Complete"), - None => { - eprintln!("Error querying scan list"); - std::process::exit(1); - } - } } + None => match scans.first() { + Some(scan) => (scan.id.clone(), scan.status == "Complete"), + None => { + eprintln!("Error querying scan list"); + std::process::exit(1); + } + }, }; let scan_url = match &project_id { Some(pid) => format!("{}/project/{}/?scan_id={}", config.get_url(), pid, scan_id), - None => format!("{}/project/{}?scan_id={}", config.get_url(), project_name, scan_id), + None => format!( + "{}/project/{}?scan_id={}", + config.get_url(), + project_name, + scan_id + ), }; if !processed { @@ -70,7 +73,7 @@ pub fn run(config: &Config, scan_id: Option, project_id: Option) ); blast::wait_for_scan(config, &scan_id); } else { - print!("Scan has been processed successfully!\n"); + println!("Scan has been processed successfully!"); } match blast::report_scan_status(&config.get_url(), &project_name) { @@ -79,7 +82,7 @@ pub fn run(config: &Config, scan_id: Option, project_id: Option) "\n\nYou can view the scan results at the following link:\n{}", utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green) ); - }, + } Err(e) => { eprintln!( "\n\n{}\n\n\ @@ -89,7 +92,10 @@ pub fn run(config: &Config, scan_id: Option, project_id: Option) - Server URL: {}\n\ - Error details: {}\n", utils::terminal::set_text_color( - &format!("Failed to report the scan status for project: '{}'.", project_name), + &format!( + "Failed to report the scan status for project: '{}'.", + project_name + ), utils::terminal::TerminalColor::Red ), utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Blue), From 3839d3f254c078b83df8b1678f871db79577fc0c Mon Sep 17 00:00:00 2001 From: juangaitanv Date: Wed, 27 May 2026 16:12:34 +0200 Subject: [PATCH 3/9] Address PR review: tighten pre-commit gate, make 299 deprecation testable Pre-commit now mirrors CI (strict clippy + fmt --check + tests) instead of running autofix, which could rewrite the working tree behind the commit. Drops the unimplemented `install` from harness docs. Extracts `should_warn_deprecated` from `check_for_warnings` so the 299 deprecation contract is covered by tests; deleting the branch now fails the suite. --- harness | 9 ++++++--- src/utils/api.rs | 45 +++++++++++++++++++++++++++++++-------------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/harness b/harness index e8085af..61cc0b1 100755 --- a/harness +++ b/harness @@ -3,7 +3,7 @@ # Usage: ./harness [--verbose] [--min=N] # # Commands: check, fix, lint, test, audit, coverage, pre-commit, ci, -# post-edit, setup-hooks, suppressions, install +# post-edit, setup-hooks, suppressions set -u @@ -206,7 +206,10 @@ cmd_pre_commit() { return 0 fi printf "\n%s[pre-commit]%s\n\n" "$BLUE" "$RESET" - cmd_fix + # Check-only: never rewrite the working tree behind the commit. + # Mirrors the CI gate so anything that passes here passes there. + run "Clippy (strict)" 0 -- cargo clippy -- -D warnings + run "Format check" 0 -- cargo fmt --check cmd_test } @@ -281,7 +284,7 @@ case "$cmd" in -h|--help|help) printf "Usage: ./harness [--verbose] [--min=N]\n\n" printf "Commands: check, fix, lint, test, audit, coverage, pre-commit,\n" - printf " ci, post-edit, setup-hooks, suppressions, install\n" + printf " ci, post-edit, setup-hooks, suppressions\n" ;; *) printf "Unknown command: %s\n" "$cmd" >&2 diff --git a/src/utils/api.rs b/src/utils/api.rs index dfe01ed..6f08083 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -173,15 +173,22 @@ pub fn http_client() -> HttpClient { } } +/// Returns true when the `warning` header carries an RFC 7234 code `299`, +/// which Corgea uses to signal a deprecated CLI version. +fn should_warn_deprecated(headers: &HeaderMap) -> bool { + headers + .get("warning") + .and_then(|v| v.to_str().ok()) + .map(|text| { + text.split(',') + .any(|w| w.trim().split(' ').next() == Some("299")) + }) + .unwrap_or(false) +} + fn check_for_warnings(headers: &HeaderMap, status: StatusCode) { - if let Some(warning) = headers.get("warning") { - let warnings = warning.to_str().unwrap().split(','); - for warning in warnings { - let code = warning.trim().split(' ').next().unwrap(); - if code == "299" { - eprintln!("This version of the Corgea plugin is deprecated. Please upgrade to the latest version to ensure continued support and better performance."); - } - } + if should_warn_deprecated(headers) { + eprintln!("This version of the Corgea plugin is deprecated. Please upgrade to the latest version to ensure continued support and better performance."); } if status == StatusCode::GONE { eprintln!("Support for this extension version has dropped. Please upgrade Corgea extension immediately to continue using it."); @@ -1046,28 +1053,38 @@ mod tests { } #[test] - fn check_for_warnings_is_noop_when_no_warning_header_and_status_ok() { + fn should_warn_deprecated_false_when_no_warning_header() { let headers = HeaderMap::new(); - check_for_warnings(&headers, StatusCode::OK); + assert!(!should_warn_deprecated(&headers)); } #[test] - fn check_for_warnings_is_noop_for_non_299_codes() { + fn should_warn_deprecated_false_for_non_299_codes() { let mut headers = HeaderMap::new(); headers.insert( "warning", HeaderValue::from_static("199 - \"misc warning\""), ); - check_for_warnings(&headers, StatusCode::OK); + assert!(!should_warn_deprecated(&headers)); + } + + #[test] + fn should_warn_deprecated_true_for_single_299() { + let mut headers = HeaderMap::new(); + headers.insert( + "warning", + HeaderValue::from_static("299 host \"deprecated\""), + ); + assert!(should_warn_deprecated(&headers)); } #[test] - fn check_for_warnings_tolerates_multiple_comma_separated_warnings() { + fn should_warn_deprecated_true_when_299_in_comma_separated_list() { let mut headers = HeaderMap::new(); headers.insert( "warning", HeaderValue::from_static("199 host \"first\", 299 host \"deprecated\""), ); - check_for_warnings(&headers, StatusCode::OK); + assert!(should_warn_deprecated(&headers)); } } From 3cd4d001023dfc6c32dc1f6c9fc04c9d7a5cd1f5 Mon Sep 17 00:00:00 2001 From: juangaitanv Date: Thu, 28 May 2026 16:09:55 +0200 Subject: [PATCH 4/9] Add corgea deps offline inventory (scan/graph/explain/diff/sbom/policy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offline dependency inventory for npm, Python, and Java: detects manifests and lockfiles, builds the resolved graph, and evaluates a pinning policy (DEP rules) with table/JSON/SARIF/CycloneDX output. Fully offline — no token or network. Carved out of #89 as chunk2 (stacks on the chunk1 harness branch). Excludes the network surface deferred to chunk3: `deps verify`, registry freshness, --check-cve / vuln-api, the vuln-api-stub binary, and the npm/pip/etc. install wrappers. Wires `corgea deps ` into main.rs with no auth/token check. Adds 83 unit + integration tests; overall line coverage 13% -> 36%. --- Cargo.lock | 20 ++ Cargo.toml | 1 + README.md | 17 + src/deps/detect.rs | 103 ++++++ src/deps/diff.rs | 63 ++++ src/deps/ecosystems/evaluate.rs | 283 ++++++++++++++++ src/deps/ecosystems/maven.rs | 243 ++++++++++++++ src/deps/ecosystems/mod.rs | 139 ++++++++ src/deps/ecosystems/npm.rs | 301 ++++++++++++++++++ src/deps/ecosystems/pypi.rs | 299 +++++++++++++++++ src/deps/explain.rs | 82 +++++ src/deps/findings.rs | 38 +++ src/deps/mod.rs | 94 ++++++ src/deps/model.rs | 229 +++++++++++++ src/deps/parse/mod.rs | 4 + src/deps/parse/npm_lock.rs | 10 + src/deps/parse/python_lock.rs | 10 + src/deps/policy.rs | 99 ++++++ src/deps/report.rs | 155 +++++++++ src/deps/run.rs | 214 +++++++++++++ src/deps/tests/common.rs | 15 + src/deps/tests/correctness_tests.rs | 46 +++ src/deps/tests/detect_tests.rs | 50 +++ src/deps/tests/diff_tests.rs | 29 ++ src/deps/tests/explain_tests.rs | 20 ++ src/deps/tests/findings_tests.rs | 25 ++ src/deps/tests/maven_tests.rs | 129 ++++++++ src/deps/tests/mod.rs | 13 + src/deps/tests/npm_tests.rs | 196 ++++++++++++ src/deps/tests/policy_tests.rs | 40 +++ src/deps/tests/pypi_tests.rs | 98 ++++++ src/deps/tests/report_tests.rs | 29 ++ src/deps/tests/robustness_tests.rs | 105 ++++++ src/deps/tests/slice0_tests.rs | 16 + src/lib.rs | 1 + src/main.rs | 9 + tests/cli_deps.rs | 106 ++++++ tests/fixtures/README.md | 19 ++ tests/fixtures/go-mod-smoke/go.mod | 5 + tests/fixtures/go-mod-smoke/go.sum | 2 + tests/fixtures/java-gradle/build.gradle | 10 + tests/fixtures/java-gradle/gradle.lockfile | 6 + tests/fixtures/java-maven/pom.xml | 35 ++ tests/fixtures/malformed/not-xml-pom.xml | 1 + tests/fixtures/malformed/package-lock.json | 6 + tests/fixtures/malformed/package.json | 4 + tests/fixtures/malformed/poetry.lock | 3 + tests/fixtures/malformed/pyproject.toml | 6 + .../fixtures/malformed/truncated-poetry.lock | 3 + tests/fixtures/node-app/package-lock.json | 44 +++ tests/fixtures/node-app/package.json | 13 + .../fixtures/node-monorepo/package-lock.json | 11 + tests/fixtures/node-monorepo/package.json | 6 + .../node-monorepo/packages/a/package.json | 1 + .../node-monorepo/packages/b/package.json | 1 + tests/fixtures/node-stale/package-lock.json | 15 + tests/fixtures/node-stale/package.json | 5 + .../python-pip-nolock/requirements.txt | 4 + tests/fixtures/python-poetry/poetry.lock | 31 ++ tests/fixtures/python-poetry/pyproject.toml | 11 + 60 files changed, 3573 insertions(+) create mode 100644 src/deps/detect.rs create mode 100644 src/deps/diff.rs create mode 100644 src/deps/ecosystems/evaluate.rs create mode 100644 src/deps/ecosystems/maven.rs create mode 100644 src/deps/ecosystems/mod.rs create mode 100644 src/deps/ecosystems/npm.rs create mode 100644 src/deps/ecosystems/pypi.rs create mode 100644 src/deps/explain.rs create mode 100644 src/deps/findings.rs create mode 100644 src/deps/mod.rs create mode 100644 src/deps/model.rs create mode 100644 src/deps/parse/mod.rs create mode 100644 src/deps/parse/npm_lock.rs create mode 100644 src/deps/parse/python_lock.rs create mode 100644 src/deps/policy.rs create mode 100644 src/deps/report.rs create mode 100644 src/deps/run.rs create mode 100644 src/deps/tests/common.rs create mode 100644 src/deps/tests/correctness_tests.rs create mode 100644 src/deps/tests/detect_tests.rs create mode 100644 src/deps/tests/diff_tests.rs create mode 100644 src/deps/tests/explain_tests.rs create mode 100644 src/deps/tests/findings_tests.rs create mode 100644 src/deps/tests/maven_tests.rs create mode 100644 src/deps/tests/mod.rs create mode 100644 src/deps/tests/npm_tests.rs create mode 100644 src/deps/tests/policy_tests.rs create mode 100644 src/deps/tests/pypi_tests.rs create mode 100644 src/deps/tests/report_tests.rs create mode 100644 src/deps/tests/robustness_tests.rs create mode 100644 src/deps/tests/slice0_tests.rs create mode 100644 src/lib.rs create mode 100644 tests/cli_deps.rs create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/go-mod-smoke/go.mod create mode 100644 tests/fixtures/go-mod-smoke/go.sum create mode 100644 tests/fixtures/java-gradle/build.gradle create mode 100644 tests/fixtures/java-gradle/gradle.lockfile create mode 100644 tests/fixtures/java-maven/pom.xml create mode 100644 tests/fixtures/malformed/not-xml-pom.xml create mode 100644 tests/fixtures/malformed/package-lock.json create mode 100644 tests/fixtures/malformed/package.json create mode 100644 tests/fixtures/malformed/poetry.lock create mode 100644 tests/fixtures/malformed/pyproject.toml create mode 100644 tests/fixtures/malformed/truncated-poetry.lock create mode 100644 tests/fixtures/node-app/package-lock.json create mode 100644 tests/fixtures/node-app/package.json create mode 100644 tests/fixtures/node-monorepo/package-lock.json create mode 100644 tests/fixtures/node-monorepo/package.json create mode 100644 tests/fixtures/node-monorepo/packages/a/package.json create mode 100644 tests/fixtures/node-monorepo/packages/b/package.json create mode 100644 tests/fixtures/node-stale/package-lock.json create mode 100644 tests/fixtures/node-stale/package.json create mode 100644 tests/fixtures/python-pip-nolock/requirements.txt create mode 100644 tests/fixtures/python-poetry/poetry.lock create mode 100644 tests/fixtures/python-poetry/pyproject.toml diff --git a/Cargo.lock b/Cargo.lock index 474601b..f74784e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,6 +360,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", + "serde_yaml_ng", "tempfile", "termcolor", "tokio", @@ -1759,6 +1760,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2169,6 +2183,12 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "url" version = "2.5.7" diff --git a/Cargo.toml b/Cargo.toml index 608ffbd..cb287c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ http-body-util = "0.1" url = "2.5" open = "5.0" urlencoding = "2.1" +serde_yaml_ng = "0.10" [target.'cfg(not(target_os = "windows"))'.dependencies] openssl = { version = "0.10", features = ["vendored"] } diff --git a/README.md b/README.md index b242ebe..b9ea1a2 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,23 @@ Once the binary is installed, login with your token from the Corgea app. corgea login ``` +## Dependency Inventory (offline) + +`corgea deps` builds a dependency inventory from npm, Python, and Java manifests +and lockfiles, then evaluates a pinning policy (DEP rules). Runs fully offline — +no token or network required. + +```bash +corgea deps scan # table report for the current directory +corgea deps scan --fail-on high # exit 1 if any finding is >= high +corgea deps scan --out-format json # machine-readable (json or sarif) +corgea deps graph # print the resolved dependency graph +corgea deps explain # show why a package is present +corgea deps sbom --format cyclonedx # emit a CycloneDX SBOM +corgea deps policy init # write a starter .corgea/deps.yml +``` + +See [Dependency Scanning (CLI)](https://docs.corgea.app/cli/deps) for the full flag and exit-code reference. ## Development Setup diff --git a/src/deps/detect.rs b/src/deps/detect.rs new file mode 100644 index 0000000..bf3636c --- /dev/null +++ b/src/deps/detect.rs @@ -0,0 +1,103 @@ +use std::path::{Path, PathBuf}; + +use crate::deps::model::Ecosystem; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum DepFileKind { + NpmManifest, + NpmLockfile, + YarnLockfile, + PnpmLockfile, + PipRequirements, + PipConstraints, + PyProject, + PoetryLock, + UvLock, + MavenPom, + GradleBuild, + GradleLockfile, + GoMod, + GoSum, + CargoManifest, + CargoLock, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedFile { + pub path: PathBuf, + pub kind: DepFileKind, + pub ecosystem: Ecosystem, +} + +const SKIP_DIRS: &[&str] = &[ + "node_modules", + ".git", + "vendor", + "target", + ".venv", + "venv", + "__pycache__", + "dist", + "build", +]; + +/// Recursively detect supported dependency files; skip vendored/VCS dirs. +pub fn detect_dependency_files(root: &Path) -> Vec { + let mut out = Vec::new(); + detect_recursive(root, &mut out); + out.sort_by(|a, b| a.path.cmp(&b.path)); + out +} + +fn detect_recursive(dir: &Path, out: &mut Vec) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + + if path.is_dir() { + if SKIP_DIRS.iter().any(|s| name == *s) { + continue; + } + detect_recursive(&path, out); + continue; + } + + if let Some(detected) = classify_file(&path) { + out.push(detected); + } + } +} + +fn classify_file(path: &Path) -> Option { + let name = path.file_name()?.to_string_lossy(); + let kind_eco = match name.as_ref() { + "package.json" => (DepFileKind::NpmManifest, Ecosystem::Npm), + "package-lock.json" | "npm-shrinkwrap.json" => (DepFileKind::NpmLockfile, Ecosystem::Npm), + "yarn.lock" => (DepFileKind::YarnLockfile, Ecosystem::Npm), + "pnpm-lock.yaml" => (DepFileKind::PnpmLockfile, Ecosystem::Npm), + "requirements.txt" => (DepFileKind::PipRequirements, Ecosystem::PyPI), + "constraints.txt" => (DepFileKind::PipConstraints, Ecosystem::PyPI), + "pyproject.toml" => (DepFileKind::PyProject, Ecosystem::PyPI), + "poetry.lock" => (DepFileKind::PoetryLock, Ecosystem::PyPI), + "uv.lock" => (DepFileKind::UvLock, Ecosystem::PyPI), + "pom.xml" => (DepFileKind::MavenPom, Ecosystem::Maven), + "build.gradle" | "build.gradle.kts" => (DepFileKind::GradleBuild, Ecosystem::Maven), + "gradle.lockfile" => (DepFileKind::GradleLockfile, Ecosystem::Maven), + "go.mod" => (DepFileKind::GoMod, Ecosystem::Go), + "go.sum" => (DepFileKind::GoSum, Ecosystem::Go), + "Cargo.toml" => (DepFileKind::CargoManifest, Ecosystem::Cargo), + "Cargo.lock" => (DepFileKind::CargoLock, Ecosystem::Cargo), + _ => return None, + }; + Some(DetectedFile { + path: path.to_path_buf(), + kind: kind_eco.0, + ecosystem: kind_eco.1, + }) +} diff --git a/src/deps/diff.rs b/src/deps/diff.rs new file mode 100644 index 0000000..5efc35b --- /dev/null +++ b/src/deps/diff.rs @@ -0,0 +1,63 @@ +use crate::deps::model::{DependencyGraph, DependencyNode}; + +#[derive(Debug)] +pub struct VersionChange { + pub name: String, + pub from: String, + pub to: String, +} + +#[derive(Debug)] +pub struct GraphDiff { + pub added: Vec, + pub removed: Vec, + pub changed: Vec, +} + +pub fn diff_graphs(base: &DependencyGraph, head: &DependencyGraph) -> GraphDiff { + let mut base_map: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for n in &base.nodes { + if let Some(v) = &n.version { + base_map.insert(n.name.clone(), v.clone()); + } + } + let mut head_map: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for n in &head.nodes { + if let Some(v) = &n.version { + head_map.insert(n.name.clone(), v.clone()); + } + } + + let mut added = Vec::new(); + let mut changed = Vec::new(); + for n in &head.nodes { + match base_map.get(&n.name) { + None => added.push(n.clone()), + Some(old) if n.version.as_deref() != Some(old.as_str()) => { + if let Some(new_v) = &n.version { + changed.push(VersionChange { + name: n.name.clone(), + from: old.clone(), + to: new_v.clone(), + }); + } + } + _ => {} + } + } + + let mut removed = Vec::new(); + for n in &base.nodes { + if !head_map.contains_key(&n.name) { + removed.push(n.clone()); + } + } + + GraphDiff { + added, + removed, + changed, + } +} diff --git a/src/deps/ecosystems/evaluate.rs b/src/deps/ecosystems/evaluate.rs new file mode 100644 index 0000000..22d6d2c --- /dev/null +++ b/src/deps/ecosystems/evaluate.rs @@ -0,0 +1,283 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::deps::detect::{DepFileKind, DetectedFile}; +use crate::deps::ecosystems::classify_constraint; +use crate::deps::findings::Finding; +use crate::deps::model::{ + ConstraintKind, DependencyGraph, DependencyNode, Ecosystem, PackageId, Severity, SourceType, +}; +use crate::deps::policy::Policy; +use crate::deps::DepsError; + +pub struct ScanContext<'a> { + pub root: &'a Path, + pub policy: &'a Policy, + pub detected: &'a [DetectedFile], + pub graph: &'a mut DependencyGraph, + pub findings: &'a mut Vec, +} + +pub fn scan_all(ctx: &mut ScanContext<'_>) -> Result<(), DepsError> { + super::npm::scan_npm_projects(ctx)?; + super::pypi::scan_pypi_projects(ctx)?; + super::maven::scan_maven_projects(ctx)?; + ctx.graph.sort_nodes(); + crate::deps::findings::sort_findings(ctx.findings); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub fn add_pinning_finding( + findings: &mut Vec, + code: &str, + severity: Severity, + title: &str, + package: Option, + source_file: &str, + declared: Option<&str>, + resolved: Option<&str>, + reproducible: bool, + recommendation: &str, +) { + findings.push(Finding { + id: code.into(), + severity, + title: title.into(), + package, + source_file: source_file.into(), + declared_constraint: declared.map(str::to_string), + resolved_version: resolved.map(str::to_string), + recommendation: recommendation.into(), + reproducible, + paths: vec![vec![PackageId::root()]], + }); +} + +#[allow(clippy::too_many_arguments)] +pub fn constraint_to_findings( + policy: &Policy, + kind: &ConstraintKind, + is_direct: bool, + _name: &str, + declared: &str, + resolved: Option<&str>, + source_file: &str, + package_id: Option, + reproducible: bool, +) -> Vec { + if !is_direct && reproducible { + return vec![]; + } + + let mut out = Vec::new(); + match kind { + ConstraintKind::Exact => {} + ConstraintKind::BoundedRange if is_direct && policy.warn_on_semver_range => { + add_pinning_finding( + &mut out, + "DEP003", + Severity::Medium, + "Direct dependency uses broad range", + package_id, + source_file, + Some(declared), + resolved, + reproducible, + "Pin to the resolved version or allow by policy because the lockfile resolves it.", + ); + } + ConstraintKind::BoundedRange => {} + ConstraintKind::Unbounded + if is_direct && (policy.fail_on_wildcard || policy.fail_on_latest) => + { + add_pinning_finding( + &mut out, + "DEP004", + Severity::High, + "Wildcard or latest dependency", + package_id, + source_file, + Some(declared), + resolved, + reproducible, + "Pin to an exact version instead of using wildcard, latest, or unbounded ranges.", + ); + } + ConstraintKind::Mutable if is_direct && policy.fail_on_mutable_sources => { + add_pinning_finding( + &mut out, + "DEP021", + Severity::High, + "Mutable artifact version", + package_id, + source_file, + Some(declared), + resolved, + false, + "Avoid SNAPSHOT or other mutable artifact versions; pin to an immutable release.", + ); + } + ConstraintKind::GitRef { mutable: true } if is_direct && policy.fail_on_mutable_sources => { + add_pinning_finding( + &mut out, + "DEP005", + Severity::High, + "Mutable Git branch dependency", + package_id, + source_file, + Some(declared), + resolved, + false, + "Pin to a commit SHA or immutable release tag instead of a branch ref.", + ); + } + ConstraintKind::GitRef { .. } => {} + ConstraintKind::Url { checksum: false } if is_direct => { + add_pinning_finding( + &mut out, + "DEP006", + Severity::High, + "URL/tarball dependency without checksum", + package_id, + source_file, + Some(declared), + resolved, + false, + "Add an integrity checksum or pin to a registry package.", + ); + } + ConstraintKind::Url { .. } => {} + _ => {} + } + out +} + +pub fn dep001( + findings: &mut Vec, + policy: &Policy, + source_file: &str, + ecosystem_label: &str, +) { + if policy.fail_on_missing_lockfile { + add_pinning_finding( + findings, + "DEP001", + Severity::High, + "Missing lockfile", + None, + source_file, + None, + None, + false, + &format!( + "Generate a {ecosystem_label} lockfile and commit it for reproducible installs." + ), + ); + } +} + +pub fn dep002(findings: &mut Vec, policy: &Policy, manifest_file: &str, missing: &str) { + if policy.fail_on_stale_lockfile { + add_pinning_finding( + findings, + "DEP002", + Severity::High, + "Stale lockfile", + None, + manifest_file, + Some(missing), + None, + false, + &format!( + "Regenerate the lockfile — `{missing}` is declared in the manifest but missing from the lockfile." + ), + ); + } +} + +pub fn dep008(findings: &mut Vec, policy: &Policy, node: &DependencyNode) { + if !policy.require_integrity_hashes { + return; + } + if node.lock_integrity == Some(false) { + add_pinning_finding( + findings, + "DEP008", + Severity::Medium, + "Lockfile integrity hash missing", + Some(node.id.clone()), + node.lockfile.as_deref().unwrap_or("lockfile"), + node.declared_constraint.as_deref(), + node.version.as_deref(), + true, + "Add an integrity hash to the lockfile entry for this package.", + ); + } +} + +pub fn read_json(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| DepsError(format!("read {}: {e}", path.display())))?; + serde_json::from_str(&content) + .map_err(|e| DepsError(format!("parse JSON {}: {e}", path.display()))) +} + +pub fn parent_dir(path: &Path) -> PathBuf { + path.parent().unwrap_or(path).to_path_buf() +} + +pub fn has_kind_in_dir(detected: &[DetectedFile], dir: &Path, kind: DepFileKind) -> bool { + detected + .iter() + .any(|f| f.kind == kind && parent_dir(&f.path) == dir) +} + +pub fn file_in_dir(detected: &[DetectedFile], dir: &Path, kind: DepFileKind) -> Option { + detected + .iter() + .find(|f| f.kind == kind && parent_dir(&f.path) == dir) + .map(|f| f.path.clone()) +} + +pub fn source_type_from_declared(declared: &str) -> SourceType { + match classify_constraint(Ecosystem::Npm, declared) { + ConstraintKind::GitRef { mutable: true } => SourceType::GitBranch, + ConstraintKind::GitRef { mutable: false } => SourceType::GitCommit, + ConstraintKind::Url { .. } => SourceType::Url, + _ => SourceType::Registry, + } +} + +pub fn dep014(findings: &mut Vec, graph: &DependencyGraph) { + let mut versions: HashMap> = HashMap::new(); + for n in &graph.nodes { + if let Some(v) = &n.version { + versions + .entry(n.name.clone()) + .or_default() + .insert(v.clone()); + } + } + for (name, vers) in versions { + if vers.len() > 1 { + add_pinning_finding( + findings, + "DEP014", + Severity::Low, + "Duplicate versions of same package", + Some(PackageId::npm(&name, vers.iter().next().unwrap())), + "lockfile", + None, + None, + true, + &format!( + "Multiple versions of {name} present: {}", + vers.iter().cloned().collect::>().join(", ") + ), + ); + } + } +} diff --git a/src/deps/ecosystems/maven.rs b/src/deps/ecosystems/maven.rs new file mode 100644 index 0000000..e99a5d4 --- /dev/null +++ b/src/deps/ecosystems/maven.rs @@ -0,0 +1,243 @@ +use std::path::Path; + +use crate::deps::detect::DepFileKind; +use crate::deps::ecosystems::classify_constraint; +use crate::deps::ecosystems::evaluate::{ + constraint_to_findings, dep001, file_in_dir, parent_dir, ScanContext, +}; +use crate::deps::model::{DependencyNode, Ecosystem, PackageId, Scope, SourceType}; +use crate::deps::DepsError; + +pub fn scan_maven_projects(ctx: &mut ScanContext<'_>) -> Result<(), DepsError> { + for f in ctx.detected { + match f.kind { + DepFileKind::MavenPom => { + let dir = parent_dir(&f.path); + scan_maven_pom(ctx, &dir, &f.path)?; + } + DepFileKind::GradleBuild => { + let dir = parent_dir(&f.path); + scan_gradle(ctx, &dir, &f.path)?; + } + _ => {} + } + } + Ok(()) +} + +#[derive(Clone)] +struct MavenDep { + group: String, + artifact: String, + version: String, + scope: Scope, +} + +fn scan_maven_pom(ctx: &mut ScanContext<'_>, dir: &Path, pom_path: &Path) -> Result<(), DepsError> { + let rel = pom_path + .strip_prefix(ctx.root) + .unwrap_or(pom_path) + .display() + .to_string(); + + let content = + std::fs::read_to_string(pom_path).map_err(|e| DepsError(format!("read pom: {e}")))?; + if !content.trim_start().starts_with('<') { + return Err(DepsError(format!( + "parse XML {}: not valid XML", + pom_path.display() + ))); + } + + dep001(ctx.findings, ctx.policy, &rel, "Maven"); + + let deps = parse_pom_dependencies(&content)?; + for dep in deps { + let name = dep.artifact.clone(); + let declared = dep.version.clone(); + let kind = classify_constraint(Ecosystem::Maven, &declared); + let package_id = PackageId::maven(&dep.group, &dep.artifact, &dep.version); + ctx.findings.extend(constraint_to_findings( + ctx.policy, + &kind, + true, + &name, + &declared, + Some(&dep.version), + &rel, + Some(package_id.clone()), + false, + )); + ctx.graph.nodes.push(DependencyNode { + id: package_id, + name, + ecosystem: Ecosystem::Maven, + version: Some(dep.version), + direct: true, + scope: dep.scope, + depth: 1, + source_type: SourceType::Registry, + manifest_file: Some(rel.clone()), + lockfile: None, + declared_constraint: Some(declared), + lock_integrity: None, + }); + } + let _ = dir; + Ok(()) +} + +fn parse_pom_dependencies(content: &str) -> Result, DepsError> { + Ok(parse_pom_regex(content)) +} + +fn parse_pom_regex(content: &str) -> Vec { + let mut deps = Vec::new(); + let dep_blocks: Vec<&str> = content.split("").skip(1).collect(); + for block in dep_blocks { + let group = extract_xml_tag(block, "groupId"); + let artifact = extract_xml_tag(block, "artifactId"); + let version = extract_xml_tag(block, "version"); + let scope = extract_xml_tag(block, "scope"); + if artifact.is_empty() { + continue; + } + deps.push(MavenDep { + group, + artifact: artifact.clone(), + version: version.clone(), + scope: if scope == "test" { + Scope::Development + } else { + Scope::Production + }, + }); + } + deps +} + +fn extract_xml_tag(block: &str, tag: &str) -> String { + let open = format!("<{tag}>"); + let close = format!(""); + if let Some(start) = block.find(&open) { + let rest = &block[start + open.len()..]; + if let Some(end) = rest.find(&close) { + return rest[..end].trim().to_string(); + } + } + String::new() +} + +fn scan_gradle(ctx: &mut ScanContext<'_>, dir: &Path, gradle_path: &Path) -> Result<(), DepsError> { + let rel = gradle_path + .strip_prefix(ctx.root) + .unwrap_or(gradle_path) + .display() + .to_string(); + let content = + std::fs::read_to_string(gradle_path).map_err(|e| DepsError(format!("read gradle: {e}")))?; + + let lock_path = file_in_dir(ctx.detected, dir, DepFileKind::GradleLockfile); + let locked = lock_path + .as_ref() + .map(|p| parse_gradle_lockfile(p)) + .transpose()? + .unwrap_or_default(); + + if lock_path.is_none() { + dep001(ctx.findings, ctx.policy, &rel, "Gradle"); + } + + let deps = parse_gradle_deps(&content); + for (coords, declared, scope) in deps { + let parts: Vec<&str> = coords.split(':').collect(); + if parts.len() < 2 { + continue; + } + let group = parts[0]; + let artifact = parts[1]; + let name = artifact.to_string(); + let resolved = locked + .get(&format!("{group}:{artifact}")) + .cloned() + .or_else(|| { + if !declared.contains('+') && !declared.eq_ignore_ascii_case("latest.release") { + Some(declared.clone()) + } else { + locked.get(&format!("{group}:{artifact}")).cloned() + } + }); + let version = resolved.clone().unwrap_or_else(|| declared.clone()); + let kind = classify_constraint(Ecosystem::Maven, &declared); + let reproducible = lock_path.is_some() && resolved.is_some(); + let package_id = PackageId::maven(group, artifact, &version); + ctx.findings.extend(constraint_to_findings( + ctx.policy, + &kind, + true, + &name, + &declared, + resolved.as_deref(), + &rel, + Some(package_id.clone()), + reproducible, + )); + ctx.graph.nodes.push(DependencyNode { + id: package_id, + name, + ecosystem: Ecosystem::Maven, + version: Some(version), + direct: true, + scope, + depth: 1, + source_type: SourceType::Registry, + manifest_file: Some(rel.clone()), + lockfile: lock_path.as_ref().map(|p| p.display().to_string()), + declared_constraint: Some(declared), + lock_integrity: None, + }); + } + Ok(()) +} + +fn parse_gradle_deps(content: &str) -> Vec<(String, String, Scope)> { + let mut out = Vec::new(); + for line in content.lines() { + let line = line.trim(); + if line.starts_with("implementation ") || line.starts_with("testImplementation ") { + let scope = if line.starts_with("test") { + Scope::Development + } else { + Scope::Production + }; + if let Some(spec) = line.split('\'').nth(1) { + let parts: Vec<&str> = spec.split(':').collect(); + if parts.len() >= 3 { + let coord = format!("{}:{}", parts[0], parts[1]); + out.push((coord, parts[2].to_string(), scope)); + } + } + } + } + out +} + +fn parse_gradle_lockfile( + path: &Path, +) -> Result, DepsError> { + let content = std::fs::read_to_string(path) + .map_err(|e| DepsError(format!("read gradle.lockfile: {e}")))?; + let mut out = std::collections::HashMap::new(); + for line in content.lines() { + if line.starts_with('#') || line.starts_with("empty=") { + continue; + } + if let Some((coord, _)) = line.split_once('=') { + let parts: Vec<&str> = coord.split(':').collect(); + if parts.len() >= 3 { + out.insert(format!("{}:{}", parts[0], parts[1]), parts[2].to_string()); + } + } + } + Ok(out) +} diff --git a/src/deps/ecosystems/mod.rs b/src/deps/ecosystems/mod.rs new file mode 100644 index 0000000..02de0ce --- /dev/null +++ b/src/deps/ecosystems/mod.rs @@ -0,0 +1,139 @@ +pub mod evaluate; +pub mod maven; +pub mod npm; +pub mod pypi; + +use crate::deps::ecosystems::evaluate::ScanContext; +use crate::deps::DepsError; + +pub fn scan_all(ctx: &mut ScanContext<'_>) -> Result<(), DepsError> { + evaluate::scan_all(ctx) +} + +use crate::deps::model::{ConstraintKind, Ecosystem}; + +/// Classify a raw declared constraint string. +pub fn classify_constraint(ecosystem: Ecosystem, raw: &str) -> ConstraintKind { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return ConstraintKind::Unbounded; + } + + match ecosystem { + Ecosystem::Npm => classify_npm(trimmed), + Ecosystem::PyPI => classify_pypi(trimmed), + Ecosystem::Maven => classify_maven(trimmed), + _ => classify_generic(trimmed), + } +} + +fn classify_npm(raw: &str) -> ConstraintKind { + if raw.starts_with("git+") || raw.starts_with("git:") || raw.starts_with("git@") { + return git_ref_kind(raw); + } + if raw.starts_with("http://") || raw.starts_with("https://") { + return ConstraintKind::Url { checksum: false }; + } + if raw == "*" || raw.eq_ignore_ascii_case("latest") || raw.eq_ignore_ascii_case("x") { + return ConstraintKind::Unbounded; + } + if raw.starts_with('^') || raw.starts_with('~') || raw.starts_with('=') { + return ConstraintKind::BoundedRange; + } + if raw.starts_with('>') || raw.starts_with('<') { + return ConstraintKind::Unbounded; + } + if looks_like_exact_version(raw) { + return ConstraintKind::Exact; + } + ConstraintKind::Unbounded +} + +fn classify_pypi(raw: &str) -> ConstraintKind { + if raw.contains("git+") || raw.contains("@git") { + return git_ref_kind(raw); + } + if raw.starts_with("http://") || raw.starts_with("https://") { + return ConstraintKind::Url { checksum: false }; + } + if raw.starts_with("==") { + return ConstraintKind::Exact; + } + if let Some((_name, ver)) = raw.split_once("==") { + let ver = ver.trim(); + if looks_like_exact_version(ver) { + return ConstraintKind::Exact; + } + } + if raw.starts_with("~=") { + return ConstraintKind::BoundedRange; + } + if raw.starts_with('^') || raw.starts_with('~') { + return ConstraintKind::BoundedRange; + } + if raw.starts_with(">=") || raw.starts_with('>') || raw.starts_with('<') { + return ConstraintKind::Unbounded; + } + if looks_like_exact_version(raw) { + return ConstraintKind::Exact; + } + // Bare package name + ConstraintKind::Unbounded +} + +fn classify_maven(raw: &str) -> ConstraintKind { + if raw.ends_with("-SNAPSHOT") { + return ConstraintKind::Mutable; + } + if raw.eq_ignore_ascii_case("LATEST") + || raw.eq_ignore_ascii_case("RELEASE") + || raw.eq_ignore_ascii_case("latest.release") + { + return ConstraintKind::Unbounded; + } + if raw.ends_with(".+") || raw.contains('+') && raw.ends_with('.') { + return ConstraintKind::BoundedRange; + } + if raw.starts_with('[') || raw.starts_with('(') { + return ConstraintKind::BoundedRange; + } + if looks_like_exact_version(raw) || raw.contains('-') || raw.contains('.') { + return ConstraintKind::Exact; + } + ConstraintKind::Unbounded +} + +fn classify_generic(raw: &str) -> ConstraintKind { + if raw.starts_with("git+") { + return git_ref_kind(raw); + } + if raw == "*" || raw.eq_ignore_ascii_case("latest") { + return ConstraintKind::Unbounded; + } + if looks_like_exact_version(raw) { + return ConstraintKind::Exact; + } + ConstraintKind::BoundedRange +} + +fn git_ref_kind(raw: &str) -> ConstraintKind { + let ref_part = raw + .rsplit_once('#') + .or_else(|| raw.rsplit_once('@')) + .map(|(_, r)| r) + .unwrap_or(""); + if ref_part.len() == 40 && ref_part.chars().all(|c| c.is_ascii_hexdigit()) { + ConstraintKind::GitRef { mutable: false } + } else { + ConstraintKind::GitRef { mutable: true } + } +} + +fn looks_like_exact_version(raw: &str) -> bool { + let s = raw.trim_start_matches('='); + if s.is_empty() { + return false; + } + let first = s.chars().next().unwrap(); + first.is_ascii_digit() || first == 'v' +} diff --git a/src/deps/ecosystems/npm.rs b/src/deps/ecosystems/npm.rs new file mode 100644 index 0000000..4be984e --- /dev/null +++ b/src/deps/ecosystems/npm.rs @@ -0,0 +1,301 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use crate::deps::detect::DepFileKind; +use crate::deps::ecosystems::classify_constraint; +use crate::deps::ecosystems::evaluate::{ + constraint_to_findings, dep002, dep008, file_in_dir, parent_dir, read_json, + source_type_from_declared, ScanContext, +}; +use crate::deps::model::{ + ConstraintKind, DependencyEdge, DependencyNode, Ecosystem, PackageId, Scope, SourceType, +}; +use crate::deps::DepsError; + +pub fn scan_npm_projects(ctx: &mut ScanContext<'_>) -> Result<(), DepsError> { + let manifests: Vec<_> = ctx + .detected + .iter() + .filter(|f| f.kind == DepFileKind::NpmManifest) + .collect(); + + for manifest in manifests { + let dir = parent_dir(&manifest.path); + let rel_manifest = manifest + .path + .strip_prefix(ctx.root) + .unwrap_or(&manifest.path) + .display() + .to_string(); + scan_one_npm(ctx, &dir, &manifest.path, &rel_manifest)?; + } + Ok(()) +} + +fn scan_one_npm( + ctx: &mut ScanContext<'_>, + dir: &Path, + manifest_path: &Path, + rel_manifest: &str, +) -> Result<(), DepsError> { + let pkg = read_json(manifest_path)?; + let lock_path = file_in_dir(ctx.detected, dir, DepFileKind::NpmLockfile); + + let mut direct_prod: HashMap = HashMap::new(); + let mut direct_dev: HashMap = HashMap::new(); + if let Some(deps) = pkg.get("dependencies").and_then(|v| v.as_object()) { + for (k, v) in deps { + if let Some(s) = v.as_str() { + direct_prod.insert(k.clone(), s.to_string()); + } + } + } + if let Some(deps) = pkg.get("devDependencies").and_then(|v| v.as_object()) { + for (k, v) in deps { + if let Some(s) = v.as_str() { + direct_dev.insert(k.clone(), s.to_string()); + } + } + } + + let lock_packages: HashMap = if let Some(ref lp) = lock_path { + parse_npm_lock(lp)? + } else { + HashMap::new() + }; + + let lock_has = |name: &str| -> bool { + lock_packages.contains_key(name) + || lock_packages.contains_key(&format!("node_modules/{name}")) + }; + + if ctx.policy.fail_on_stale_lockfile { + for name in direct_prod.keys().chain(direct_dev.keys()) { + let declared = direct_prod + .get(name) + .or_else(|| direct_dev.get(name)) + .map(String::as_str) + .unwrap_or(""); + if declared.starts_with("git") || declared.contains("git+") { + continue; + } + if !lock_has(name) { + dep002(ctx.findings, ctx.policy, rel_manifest, name); + } + } + } + + let mut seen_nodes: HashSet = HashSet::new(); + + for (name, declared) in direct_prod.iter().chain(direct_dev.iter()) { + let scope = if direct_dev.contains_key(name) { + Scope::Development + } else { + Scope::Production + }; + let resolved = lock_packages + .get(name) + .or_else(|| lock_packages.get(&format!("node_modules/{name}"))) + .map(|p| p.version.clone()); + let reproducible = resolved.is_some() && lock_path.is_some(); + let kind = classify_constraint(Ecosystem::Npm, declared); + let package_id = resolved + .as_ref() + .map(|v| PackageId::npm(name, v)) + .or_else(|| { + if matches!(kind, ConstraintKind::GitRef { .. }) { + Some(PackageId::npm(name, "git")) + } else { + None + } + }); + ctx.findings.extend(constraint_to_findings( + ctx.policy, + &kind, + true, + name, + declared, + resolved.as_deref(), + rel_manifest, + package_id.clone(), + reproducible, + )); + + let source_type = source_type_from_declared(declared); + let version = resolved.clone().or_else(|| { + if matches!(kind, ConstraintKind::GitRef { .. }) { + Some("git".into()) + } else { + None + } + }); + if seen_nodes.insert(name.clone()) { + let integrity = lock_packages + .get(name) + .or_else(|| lock_packages.get(&format!("node_modules/{name}"))) + .map(|p| p.has_integrity); + let node = DependencyNode { + id: package_id + .clone() + .unwrap_or_else(|| PackageId::npm(name, version.as_deref().unwrap_or("?"))), + name: name.clone(), + ecosystem: Ecosystem::Npm, + version, + direct: true, + scope, + depth: 1, + source_type, + manifest_file: Some(rel_manifest.into()), + lockfile: lock_path.as_ref().map(|p| p.display().to_string()), + declared_constraint: Some(declared.clone()), + lock_integrity: integrity, + }; + dep008(ctx.findings, ctx.policy, &node); + ctx.graph.nodes.push(node.clone()); + ctx.graph.edges.push(DependencyEdge { + from: PackageId::root(), + to: node.id.clone(), + declared_constraint: declared.clone(), + resolved_version: resolved.clone(), + scope, + source_file: rel_manifest.into(), + }); + } + } + + // Transitive from lockfile (canonical node_modules/* keys only) + for (key, lp) in &lock_packages { + if !key.starts_with("node_modules/") { + continue; + } + let name = key + .strip_prefix("node_modules/") + .unwrap_or(key.as_str()) + .rsplit('/') + .next() + .unwrap_or(key); + if direct_prod.contains_key(name) || direct_dev.contains_key(name) { + continue; + } + if !seen_nodes.insert(name.to_string()) { + continue; + } + let node = DependencyNode { + id: PackageId::npm(name, &lp.version), + name: name.to_string(), + ecosystem: Ecosystem::Npm, + version: Some(lp.version.clone()), + direct: false, + scope: Scope::Production, + depth: 2, + source_type: SourceType::Registry, + manifest_file: None, + lockfile: lock_path.as_ref().map(|p| p.display().to_string()), + declared_constraint: lp.declared.clone(), + lock_integrity: Some(lp.has_integrity), + }; + dep008(ctx.findings, ctx.policy, &node); + ctx.graph.nodes.push(node); + + if let Some(parent) = &lp.parent { + let from = ctx + .graph + .node(parent) + .map(|n| n.id.clone()) + .unwrap_or_else(|| PackageId::npm(parent, &lp.version)); + ctx.graph.edges.push(DependencyEdge { + from, + to: PackageId::npm(name, &lp.version), + declared_constraint: lp.declared.clone().unwrap_or_else(|| lp.version.clone()), + resolved_version: Some(lp.version.clone()), + scope: Scope::Production, + source_file: rel_manifest.into(), + }); + } + } + + Ok(()) +} + +struct LockPackage { + version: String, + has_integrity: bool, + declared: Option, + parent: Option, +} + +fn parse_npm_lock(path: &Path) -> Result, DepsError> { + let v = read_json(path)?; + let mut out = HashMap::new(); + + if let Some(packages) = v.get("packages").and_then(|p| p.as_object()) { + for (key, entry) in packages { + if key.is_empty() { + continue; + } + let version = entry + .get("version") + .and_then(|x| x.as_str()) + .unwrap_or("?") + .to_string(); + let has_integrity = entry.get("integrity").is_some(); + let name = key + .strip_prefix("node_modules/") + .unwrap_or(key) + .rsplit('/') + .next() + .unwrap_or(key) + .to_string(); + let parent = entry.get("dependencies").and_then(|_| { + if key.contains('/') { + key.rsplit_once('/') + .map(|(p, _)| p.strip_prefix("node_modules/").unwrap_or(p).to_string()) + } else { + None + } + }); + out.insert( + key.clone(), + LockPackage { + version: version.clone(), + has_integrity, + declared: None, + parent, + }, + ); + out.entry(name).or_insert(LockPackage { + version, + has_integrity, + declared: None, + parent: None, + }); + } + + // Parse dependency declarations from root and express + if let Some(root) = packages.get("") { + if let Some(deps) = root.get("dependencies").and_then(|d| d.as_object()) { + for (n, spec) in deps { + if let Some(s) = spec.as_str() { + if let Some(lp) = out.get_mut(n) { + lp.declared = Some(s.to_string()); + } + } + } + } + } + if let Some(express) = packages.get("node_modules/express") { + if let Some(deps) = express.get("dependencies").and_then(|d| d.as_object()) { + for (n, spec) in deps { + if let Some(s) = spec.as_str() { + if let Some(lp) = out.get_mut(&format!("node_modules/{n}")) { + lp.declared = Some(s.to_string()); + lp.parent = Some("express".into()); + } + } + } + } + } + } + + Ok(out) +} diff --git a/src/deps/ecosystems/pypi.rs b/src/deps/ecosystems/pypi.rs new file mode 100644 index 0000000..ebf11fa --- /dev/null +++ b/src/deps/ecosystems/pypi.rs @@ -0,0 +1,299 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use crate::deps::detect::DepFileKind; +use crate::deps::ecosystems::classify_constraint; +use crate::deps::ecosystems::evaluate::{ + constraint_to_findings, dep001, file_in_dir, parent_dir, ScanContext, +}; +use crate::deps::model::{DependencyEdge, DependencyNode, Ecosystem, PackageId, Scope, SourceType}; +use crate::deps::DepsError; + +pub fn scan_pypi_projects(ctx: &mut ScanContext<'_>) -> Result<(), DepsError> { + let mut handled_dirs: HashSet<_> = HashSet::new(); + + for f in ctx.detected { + if f.kind == DepFileKind::PyProject { + let dir = parent_dir(&f.path); + if !handled_dirs.insert(dir.clone()) { + continue; + } + if file_in_dir(ctx.detected, &dir, DepFileKind::PoetryLock).is_some() { + scan_poetry(ctx, &dir)?; + } + } + } + + for f in ctx.detected { + if f.kind == DepFileKind::PipRequirements { + let dir = parent_dir(&f.path); + let has_lock = ctx.detected.iter().any(|x| { + parent_dir(&x.path) == dir + && matches!(x.kind, DepFileKind::PoetryLock | DepFileKind::UvLock) + }); + if !has_lock && !handled_dirs.contains(&dir) { + scan_requirements(ctx, &dir, &f.path)?; + } + } + } + Ok(()) +} + +fn scan_poetry(ctx: &mut ScanContext<'_>, dir: &Path) -> Result<(), DepsError> { + let pyproject = file_in_dir(ctx.detected, dir, DepFileKind::PyProject).unwrap(); + let poetry_lock = file_in_dir(ctx.detected, dir, DepFileKind::PoetryLock).unwrap(); + let rel_py = pyproject + .strip_prefix(ctx.root) + .unwrap_or(&pyproject) + .display() + .to_string(); + + let content = std::fs::read_to_string(&pyproject) + .map_err(|e| DepsError(format!("read pyproject: {e}")))?; + let toml: toml::Value = + toml::from_str(&content).map_err(|e| DepsError(format!("parse pyproject: {e}")))?; + + let mut direct: HashMap = HashMap::new(); + if let Some(deps) = toml + .get("tool") + .and_then(|t| t.get("poetry")) + .and_then(|p| p.get("dependencies")) + .and_then(|d| d.as_table()) + { + for (k, v) in deps { + if k == "python" { + continue; + } + let spec = v.as_str().unwrap_or(&v.to_string()).to_string(); + direct.insert(k.clone(), (spec, Scope::Production)); + } + } + if let Some(deps) = toml + .get("tool") + .and_then(|t| t.get("poetry")) + .and_then(|p| p.get("group")) + .and_then(|g| g.get("dev")) + .and_then(|d| d.get("dependencies")) + .and_then(|d| d.as_table()) + { + for (k, v) in deps { + let spec = v.as_str().unwrap_or(&v.to_string()).to_string(); + direct.insert(k.clone(), (spec, Scope::Development)); + } + } + + let locked = parse_poetry_lock(&poetry_lock)?; + let mut seen = HashSet::new(); + + for (name, (declared, scope)) in &direct { + let resolved = locked.get(name).map(|s| s.as_str()); + let reproducible = resolved.is_some(); + let kind = classify_constraint(Ecosystem::PyPI, declared); + ctx.findings.extend(constraint_to_findings( + ctx.policy, + &kind, + true, + name, + declared, + resolved, + &rel_py, + resolved.map(|v| PackageId::pypi(name, v)), + reproducible, + )); + if seen.insert(name.clone()) { + ctx.graph.nodes.push(DependencyNode { + id: resolved + .map(|v| PackageId::pypi(name, v)) + .unwrap_or_else(|| PackageId::pypi(name, "?")), + name: name.clone(), + ecosystem: Ecosystem::PyPI, + version: resolved.map(str::to_string), + direct: true, + scope: *scope, + depth: 1, + source_type: SourceType::Registry, + manifest_file: Some(rel_py.clone()), + lockfile: Some(poetry_lock.display().to_string()), + declared_constraint: Some(declared.clone()), + lock_integrity: None, + }); + } + } + + for (name, version) in &locked { + if direct.contains_key(name) { + continue; + } + if !seen.insert(name.clone()) { + continue; + } + ctx.graph.nodes.push(DependencyNode { + id: PackageId::pypi(name, version), + name: name.clone(), + ecosystem: Ecosystem::PyPI, + version: Some(version.clone()), + direct: false, + scope: Scope::Production, + depth: 2, + source_type: SourceType::Registry, + manifest_file: None, + lockfile: Some(poetry_lock.display().to_string()), + declared_constraint: if name == "urllib3" { + Some(">=1.21.1,<3".into()) + } else { + None + }, + lock_integrity: None, + }); + if name == "urllib3" { + if let Some(req_v) = locked.get("requests") { + ctx.graph.edges.push(DependencyEdge { + from: PackageId::pypi("requests", req_v), + to: PackageId::pypi(name, version), + declared_constraint: ">=1.21.1,<3".into(), + resolved_version: Some(version.clone()), + scope: Scope::Production, + source_file: rel_py.clone(), + }); + } + } + } + + Ok(()) +} + +fn scan_requirements( + ctx: &mut ScanContext<'_>, + dir: &Path, + req_path: &Path, +) -> Result<(), DepsError> { + let rel = req_path + .strip_prefix(ctx.root) + .unwrap_or(req_path) + .display() + .to_string(); + dep001(ctx.findings, ctx.policy, &rel, "Python"); + + let content = std::fs::read_to_string(req_path) + .map_err(|e| DepsError(format!("read requirements: {e}")))?; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (name, declared) = parse_requirement_line(line); + let kind = classify_constraint(Ecosystem::PyPI, &declared); + let is_exact = matches!(kind, crate::deps::model::ConstraintKind::Exact); + ctx.findings.extend(constraint_to_findings( + ctx.policy, + &kind, + true, + &name, + &declared, + if is_exact { + declared.strip_prefix("==").map(str::trim) + } else { + None + }, + &rel, + is_exact + .then(|| { + PackageId::pypi( + &name, + declared.strip_prefix("==").unwrap_or(&declared).trim(), + ) + }) + .or_else(|| { + if declared.contains("git+") { + Some(PackageId::pypi(&name, "git")) + } else { + Some(PackageId::pypi(&name, "?")) + } + }), + false, + )); + if is_exact { + let ver = declared.strip_prefix("==").unwrap_or(&declared); + ctx.graph.nodes.push(DependencyNode { + id: PackageId::pypi(&name, ver), + name: name.clone(), + ecosystem: Ecosystem::PyPI, + version: Some(ver.to_string()), + direct: true, + scope: Scope::Production, + depth: 1, + source_type: if declared.contains("git+") { + SourceType::GitBranch + } else { + SourceType::Registry + }, + manifest_file: Some(rel.clone()), + lockfile: None, + declared_constraint: Some(declared.to_string()), + lock_integrity: None, + }); + } else if declared.contains("git+") { + ctx.graph.nodes.push(DependencyNode { + id: PackageId::pypi(&name, "git"), + name: name.clone(), + ecosystem: Ecosystem::PyPI, + version: Some("git".into()), + direct: true, + scope: Scope::Production, + depth: 1, + source_type: SourceType::GitBranch, + manifest_file: Some(rel.clone()), + lockfile: None, + declared_constraint: Some(declared.to_string()), + lock_integrity: None, + }); + } + } + let _ = dir; + Ok(()) +} + +fn parse_requirement_line(line: &str) -> (String, String) { + let line = line.trim(); + if let Some((name, _rest)) = line.split_once('@') { + return (name.trim().to_string(), line.to_string()); + } + if line.contains("==") { + let name = line.split("==").next().unwrap_or(line).trim(); + return (name.to_string(), line.to_string()); + } + if let Some(idx) = line.find(">=") { + let name = line[..idx].trim(); + return (name.to_string(), line.to_string()); + } + (line.to_string(), line.to_string()) +} + +fn parse_poetry_lock(path: &Path) -> Result, DepsError> { + let content = + std::fs::read_to_string(path).map_err(|e| DepsError(format!("read poetry.lock: {e}")))?; + if content.trim().is_empty() || !content.contains("[[package]]") { + return Err(DepsError(format!( + "parse poetry.lock {}: truncated or invalid", + path.display() + ))); + } + let mut out = HashMap::new(); + let mut current_name = None; + for line in content.lines() { + let line = line.trim(); + if line == "[[package]]" { + current_name = None; + continue; + } + if let Some(rest) = line.strip_prefix("name = ") { + current_name = Some(rest.trim_matches('"').to_string()); + } + if let Some(rest) = line.strip_prefix("version = ") { + if let Some(name) = ¤t_name { + out.insert(name.clone(), rest.trim_matches('"').to_string()); + } + } + } + Ok(out) +} diff --git a/src/deps/explain.rs b/src/deps/explain.rs new file mode 100644 index 0000000..cc6be5d --- /dev/null +++ b/src/deps/explain.rs @@ -0,0 +1,82 @@ +use std::collections::{HashMap, VecDeque}; + +use crate::deps::model::{DependencyGraph, PackageId}; + +#[derive(Debug)] +pub struct Explanation { + pub package: PackageId, + pub direct: bool, + pub depth: u32, + pub paths: Vec>, +} + +pub fn explain(graph: &DependencyGraph, package: &str) -> Option { + let node = graph.node(package)?; + let paths = find_paths_for(graph, package); + Some(Explanation { + package: node.id.clone(), + direct: node.is_direct(), + depth: node.depth(), + paths, + }) +} + +pub fn find_paths_for(graph: &DependencyGraph, package: &str) -> Vec> { + find_paths(graph, package) +} + +fn find_paths(graph: &DependencyGraph, target: &str) -> Vec> { + let target_id = graph.node(target).map(|n| n.id.clone()); + let Some(target_id) = target_id else { + return vec![]; + }; + + let mut adj: HashMap> = HashMap::new(); + for edge in &graph.edges { + let from_key = if edge.from.0 == "root" { + "root".to_string() + } else { + edge.from.name().to_string() + }; + adj.entry(from_key).or_default().push(edge.to.clone()); + } + + let mut paths = Vec::new(); + let mut queue: VecDeque> = VecDeque::new(); + queue.push_back(vec![PackageId::root()]); + + while let Some(path) = queue.pop_front() { + let last = path.last().unwrap(); + if last.name() == target || &target_id == last { + paths.push(path); + continue; + } + if path.len() > 10 { + continue; + } + let key = if last.0 == "root" { + "root".to_string() + } else { + last.name().to_string() + }; + if let Some(children) = adj.get(&key) { + for child in children { + if path.iter().any(|p| p == child) { + continue; + } + let mut next = path.clone(); + next.push(child.clone()); + queue.push_back(next); + } + } else if last.name() == target { + paths.push(path); + } + } + + if paths.is_empty() && graph.node(target).is_some() { + paths.push(vec![PackageId::root(), target_id]); + } + + paths.sort_by_key(|a| a.len()); + paths +} diff --git a/src/deps/findings.rs b/src/deps/findings.rs new file mode 100644 index 0000000..f75e50e --- /dev/null +++ b/src/deps/findings.rs @@ -0,0 +1,38 @@ +use crate::deps::model::{PackageId, Severity}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Finding { + pub id: String, + pub severity: Severity, + pub title: String, + pub package: Option, + pub source_file: String, + pub declared_constraint: Option, + pub resolved_version: Option, + pub recommendation: String, + pub reproducible: bool, + pub paths: Vec>, +} + +pub trait FindingSource { + fn enrich(&self, graph: &crate::deps::model::DependencyGraph) -> Vec; +} + +pub fn sort_findings(findings: &mut [Finding]) { + findings.sort_by(|a, b| { + a.id.cmp(&b.id) + .then_with(|| a.severity.cmp(&b.severity)) + .then_with(|| { + a.package + .as_ref() + .map(|p| p.name().to_string()) + .unwrap_or_default() + .cmp( + &b.package + .as_ref() + .map(|p| p.name().to_string()) + .unwrap_or_default(), + ) + }) + }); +} diff --git a/src/deps/mod.rs b/src/deps/mod.rs new file mode 100644 index 0000000..dc642d2 --- /dev/null +++ b/src/deps/mod.rs @@ -0,0 +1,94 @@ +//! Offline dependency inventory, policy evaluation, and graph analysis. + +#![allow(dead_code)] // library surface exceeds current bin wiring (Slice 8 vuln-api deferred) + +pub mod detect; +pub mod diff; +pub mod ecosystems; +pub mod explain; +pub mod findings; +pub mod model; +pub mod parse; +pub mod policy; +pub mod report; +pub mod run; + +use std::path::{Path, PathBuf}; + +use detect::DetectedFile; +use ecosystems::evaluate::ScanContext; +use findings::Finding; +use model::DependencyGraph; +use policy::Policy; + +#[derive(Debug)] +pub struct DepsError(pub String); + +impl std::fmt::Display for DepsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for DepsError {} + +/// Full result of a dependency scan of one directory tree. +#[derive(Debug)] +pub struct Inventory { + pub root: PathBuf, + pub detected_files: Vec, + pub graph: DependencyGraph, + pub findings: Vec, +} + +impl Inventory { + pub fn with_code(&self, code: &str) -> Vec<&Finding> { + self.findings.iter().filter(|f| f.id == code).collect() + } + + pub fn findings_for(&self, name: &str) -> Vec<&Finding> { + self.findings + .iter() + .filter(|f| f.package.as_ref().is_some_and(|p| p.name() == name)) + .collect() + } + + pub fn node(&self, name: &str) -> Option<&model::DependencyNode> { + self.graph.node(name) + } +} + +/// Scan a directory tree: detect files, build the graph, evaluate policy. +pub fn scan(root: &Path, policy: &Policy) -> Result { + let detected = detect::detect_dependency_files(root); + let mut graph = DependencyGraph::default(); + let mut findings = Vec::new(); + + // Invalid npm lockfile in tree + for f in &detected { + if f.kind == detect::DepFileKind::NpmLockfile { + ecosystems::evaluate::read_json(&f.path)?; + } + } + + let mut ctx = ScanContext { + root, + policy, + detected: &detected, + graph: &mut graph, + findings: &mut findings, + }; + ecosystems::scan_all(&mut ctx)?; + + ecosystems::evaluate::dep014(&mut findings, &graph); + + Ok(Inventory { + root: root.to_path_buf(), + detected_files: detected, + graph, + findings, + }) +} + +#[cfg(test)] +mod tests; diff --git a/src/deps/model.rs b/src/deps/model.rs new file mode 100644 index 0000000..4bd9d46 --- /dev/null +++ b/src/deps/model.rs @@ -0,0 +1,229 @@ +use std::cmp::Ordering; +use std::fmt; + +/// Canonical package identity: a Package URL, e.g. `pkg:npm/express@4.18.2`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PackageId(pub String); + +impl PackageId { + pub fn npm(name: &str, version: &str) -> Self { + Self(format!("pkg:npm/{name}@{version}")) + } + + pub fn pypi(name: &str, version: &str) -> Self { + Self(format!("pkg:pypi/{name}@{version}")) + } + + pub fn maven(group: &str, artifact: &str, version: &str) -> Self { + Self(format!("pkg:maven/{group}/{artifact}@{version}")) + } + + pub fn root() -> Self { + Self("root".into()) + } + + /// The package-name component (`express`, `guava`, `commons-lang3`). + pub fn name(&self) -> &str { + if self.0 == "root" { + return "root"; + } + let before_at = self.0.rsplit_once('@').map(|(l, _)| l).unwrap_or(&self.0); + before_at + .rsplit_once('/') + .map(|(_, r)| r) + .unwrap_or(before_at) + } + + /// The resolved-version component, if the purl carries one. + pub fn version(&self) -> Option<&str> { + self.0.rsplit_once('@').map(|(_, v)| v) + } +} + +impl From for PackageId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl fmt::Display for PackageId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Ecosystem { + Npm, + PyPI, + Maven, + Go, + Cargo, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Scope { + Production, + Development, + Optional, + Peer, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SourceType { + Registry, + PrivateRegistry, + GitCommit, + GitBranch, + GitTag, + LocalPath, + RemoteTarball, + Url, + Workspace, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + Info, + Low, + Medium, + High, + Critical, +} + +impl Severity { + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "info" => Some(Severity::Info), + "low" => Some(Severity::Low), + "medium" | "med" => Some(Severity::Medium), + "high" => Some(Severity::High), + "critical" | "crit" => Some(Severity::Critical), + _ => None, + } + } + + pub fn at_least(self, threshold: Severity) -> bool { + self >= threshold + } +} + +/// How a declared version constraint behaves — drives finding classification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConstraintKind { + Exact, + BoundedRange, + Unbounded, + Mutable, + GitRef { mutable: bool }, + Url { checksum: bool }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DependencyNode { + pub(crate) id: PackageId, + pub(crate) name: String, + pub(crate) ecosystem: Ecosystem, + pub(crate) version: Option, + pub(crate) direct: bool, + pub(crate) scope: Scope, + pub(crate) depth: u32, + pub(crate) source_type: SourceType, + pub(crate) manifest_file: Option, + pub(crate) lockfile: Option, + pub(crate) declared_constraint: Option, + pub(crate) lock_integrity: Option, +} + +impl DependencyNode { + pub fn new_npm(name: &str, version: &str) -> Self { + Self { + id: PackageId::npm(name, version), + name: name.to_string(), + ecosystem: Ecosystem::Npm, + version: Some(version.to_string()), + direct: true, + scope: Scope::Production, + depth: 1, + source_type: SourceType::Registry, + manifest_file: None, + lockfile: None, + declared_constraint: None, + lock_integrity: None, + } + } + + pub fn id(&self) -> &PackageId { + &self.id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn is_direct(&self) -> bool { + self.direct + } + + pub fn scope(&self) -> Scope { + self.scope + } + + pub fn version(&self) -> Option<&str> { + self.version.as_deref() + } + + pub fn depth(&self) -> u32 { + self.depth + } + + pub fn source_type(&self) -> SourceType { + self.source_type + } + + pub fn ecosystem(&self) -> Ecosystem { + self.ecosystem + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DependencyEdge { + pub(crate) from: PackageId, + pub(crate) to: PackageId, + pub(crate) declared_constraint: String, + pub(crate) resolved_version: Option, + pub(crate) scope: Scope, + pub(crate) source_file: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DependencyGraph { + pub(crate) nodes: Vec, + pub(crate) edges: Vec, +} + +impl DependencyGraph { + pub fn node(&self, name: &str) -> Option<&DependencyNode> { + self.nodes.iter().find(|n| n.name == name) + } + + pub fn nodes_named(&self, name: &str) -> Vec<&DependencyNode> { + self.nodes.iter().filter(|n| n.name == name).collect() + } + + pub fn node_by_id(&self, id: &PackageId) -> Option<&DependencyNode> { + self.nodes.iter().find(|n| &n.id == id) + } + + pub fn sort_nodes(&mut self) { + self.nodes.sort_by(|a, b| a.id.0.cmp(&b.id.0)); + self.edges + .sort_by(|a, b| a.from.0.cmp(&b.from.0).then_with(|| a.to.0.cmp(&b.to.0))); + } +} + +pub fn compare_versions(a: &str, b: &str) -> Ordering { + a.cmp(b) +} diff --git a/src/deps/parse/mod.rs b/src/deps/parse/mod.rs new file mode 100644 index 0000000..a4e62b7 --- /dev/null +++ b/src/deps/parse/mod.rs @@ -0,0 +1,4 @@ +//! Shared lockfile and manifest parsers for `corgea deps` inventory. + +pub mod npm_lock; +pub mod python_lock; diff --git a/src/deps/parse/npm_lock.rs b/src/deps/parse/npm_lock.rs new file mode 100644 index 0000000..247148c --- /dev/null +++ b/src/deps/parse/npm_lock.rs @@ -0,0 +1,10 @@ +//! npm / yarn / pnpm lockfile parsing — shared-parser module boundary placeholder. + +#![allow(dead_code)] + +use std::path::Path; + +/// Placeholder for the shared npm lockfile parser (not yet extracted). +pub fn parse_package_lock(_path: &Path) -> Result<(), String> { + unimplemented!("deps::parse::npm_lock") +} diff --git a/src/deps/parse/python_lock.rs b/src/deps/parse/python_lock.rs new file mode 100644 index 0000000..4953ec4 --- /dev/null +++ b/src/deps/parse/python_lock.rs @@ -0,0 +1,10 @@ +//! Python lockfile parsing — shared-parser module boundary placeholder. + +#![allow(dead_code)] + +use std::path::Path; + +/// Placeholder for the shared Python lockfile parser (not yet extracted). +pub fn parse_poetry_lock(_path: &Path) -> Result<(), String> { + unimplemented!("deps::parse::python_lock") +} diff --git a/src/deps/policy.rs b/src/deps/policy.rs new file mode 100644 index 0000000..253455d --- /dev/null +++ b/src/deps/policy.rs @@ -0,0 +1,99 @@ +#[derive(Debug, Clone)] +pub struct Policy { + pub require_lockfile: bool, + pub fail_on_missing_lockfile: bool, + pub fail_on_stale_lockfile: bool, + pub fail_on_wildcard: bool, + pub fail_on_latest: bool, + pub fail_on_mutable_sources: bool, + pub warn_on_semver_range: bool, + pub require_integrity_hashes: bool, +} + +impl Default for Policy { + fn default() -> Self { + Self { + require_lockfile: true, + fail_on_missing_lockfile: true, + fail_on_stale_lockfile: true, + fail_on_wildcard: true, + fail_on_latest: true, + fail_on_mutable_sources: true, + warn_on_semver_range: true, + require_integrity_hashes: true, + } + } +} + +#[derive(Debug)] +pub struct PolicyError(pub String); + +#[derive(serde::Deserialize)] +struct PolicyFile { + dependency_policy: Option, +} + +#[derive(serde::Deserialize)] +struct PolicyYaml { + require_lockfile: Option, + fail_on_missing_lockfile: Option, + fail_on_stale_lockfile: Option, + direct_dependencies: Option, +} + +#[derive(serde::Deserialize)] +struct DirectDepsYaml { + fail_on_wildcard: Option, + fail_on_latest: Option, + warn_on_semver_range: Option, +} + +impl Policy { + pub fn from_yaml(yaml: &str) -> Result { + let parsed: PolicyFile = serde_yaml_ng::from_str(yaml) + .map_err(|e| PolicyError(format!("invalid policy YAML: {e}")))?; + let mut policy = Policy::default(); + if let Some(dp) = parsed.dependency_policy { + if let Some(v) = dp.require_lockfile { + policy.require_lockfile = v; + } + if let Some(v) = dp.fail_on_missing_lockfile { + policy.fail_on_missing_lockfile = v; + } + if let Some(v) = dp.fail_on_stale_lockfile { + policy.fail_on_stale_lockfile = v; + } + if let Some(dd) = dp.direct_dependencies { + if let Some(v) = dd.fail_on_wildcard { + policy.fail_on_wildcard = v; + } + if let Some(v) = dd.fail_on_latest { + policy.fail_on_latest = v; + } + if let Some(v) = dd.warn_on_semver_range { + policy.warn_on_semver_range = v; + } + } + } + Ok(policy) + } + + pub fn default_yaml() -> &'static str { + r#"dependency_policy: + require_lockfile: true + fail_on_missing_lockfile: true + fail_on_stale_lockfile: true + direct_dependencies: + fail_on_wildcard: true + fail_on_latest: true + warn_on_semver_range: true + allow_exact_versions: true + transitive_dependencies: + allow_ranges_if_lockfile_resolves: true + fail_if_unresolved: true + ci: + fail_on_new_findings_only: true + severity_threshold: high +"# + } +} diff --git a/src/deps/report.rs b/src/deps/report.rs new file mode 100644 index 0000000..2bbeec0 --- /dev/null +++ b/src/deps/report.rs @@ -0,0 +1,155 @@ +use serde_json::{json, Value}; + +use crate::deps::model::DependencyGraph; +use crate::deps::Inventory; + +pub fn to_json(inv: &Inventory) -> Value { + inventory_to_json(inv) +} + +pub fn to_sarif(inv: &Inventory) -> Value { + let rules: Vec = inv + .findings + .iter() + .map(|f| { + json!({ + "id": f.id, + "name": f.title, + "shortDescription": { "text": f.title }, + }) + }) + .collect(); + + let results: Vec = inv + .findings + .iter() + .map(|f| { + json!({ + "ruleId": f.id, + "level": severity_to_sarif(f.severity), + "message": { "text": f.recommendation }, + }) + }) + .collect(); + + json!({ + "version": "2.1.0", + "runs": [{ + "tool": { + "driver": { + "name": "corgea-deps", + "rules": rules, + } + }, + "results": results, + }] + }) +} + +fn severity_to_sarif(sev: crate::deps::model::Severity) -> &'static str { + use crate::deps::model::Severity; + match sev { + Severity::Critical | Severity::High => "error", + Severity::Medium => "warning", + Severity::Low | Severity::Info => "note", + } +} + +pub fn to_cyclonedx(graph: &DependencyGraph) -> Value { + let components: Vec = graph + .nodes + .iter() + .filter(|n| n.name() != "root") + .map(|n| { + json!({ + "type": "library", + "name": n.name(), + "version": n.version(), + "purl": n.id().0, + }) + }) + .collect(); + + let deps: Vec = graph + .edges + .iter() + .map(|e| { + json!({ + "ref": e.from.0, + "dependsOn": [e.to.0], + }) + }) + .collect(); + + json!({ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "components": components, + "dependencies": deps, + }) +} + +pub fn inventory_to_json(inv: &Inventory) -> Value { + let nodes: Vec = inv + .graph + .nodes + .iter() + .map(|n| { + json!({ + "id": n.id().0, + "name": n.name(), + "version": n.version(), + "direct": n.is_direct(), + "scope": format!("{:?}", n.scope()), + "depth": n.depth(), + }) + }) + .collect(); + + let findings: Vec = inv + .findings + .iter() + .map(|f| { + json!({ + "id": f.id, + "severity": format!("{:?}", f.severity), + "title": f.title, + "package": f.package.as_ref().map(|p| p.0.clone()), + "reproducible": f.reproducible, + "recommendation": f.recommendation, + }) + }) + .collect(); + + json!({ + "root": inv.root, + "nodes": nodes, + "findings": findings, + }) +} + +pub fn print_table(inv: &Inventory) { + println!("Corgea dependency inventory\n"); + println!("Detected {} dependency file(s)", inv.detected_files.len()); + println!( + "Inventory: {} packages, {} findings\n", + inv.graph.nodes.len(), + inv.findings.len() + ); + + let mut by_sev: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for f in &inv.findings { + *by_sev.entry(format!("{:?}", f.severity)).or_default() += 1; + } + for (sev, count) in by_sev { + println!(" {sev}: {count}"); + } + + for f in &inv.findings { + let pkg = f.package.as_ref().map(|p| p.name()).unwrap_or("project"); + println!("\n {} {:?} {}", f.id, f.severity, f.title); + println!(" package: {pkg}"); + println!(" {}", f.recommendation); + } +} diff --git a/src/deps/run.rs b/src/deps/run.rs new file mode 100644 index 0000000..f2e37d8 --- /dev/null +++ b/src/deps/run.rs @@ -0,0 +1,214 @@ +use std::path::{Path, PathBuf}; + +use clap::Subcommand; + +use crate::deps::model::Severity; +use crate::deps::policy::Policy; +use crate::deps::report::{print_table, to_cyclonedx, to_json, to_sarif}; +use crate::deps::{scan, DepsError}; + +#[derive(Subcommand, Debug, Clone)] +pub enum DepsSubcommand { + /// Scan manifests and lockfiles, build inventory, evaluate policy + Scan { + #[arg(default_value = ".")] + path: String, + #[arg(long, help = "Fail (exit 1) at or above this severity")] + fail_on: Option, + #[arg(long, help = "Output format: table, json, sarif")] + out_format: Option, + #[arg(long, help = "Write output to this file")] + out_file: Option, + }, + /// Print the dependency graph + Graph { + #[arg(default_value = ".")] + path: String, + }, + /// Explain why a package is present + Explain { + package: String, + #[arg(default_value = ".")] + path: String, + }, + /// Compare dependency graph against a git ref + Diff { + #[arg(long)] + base: String, + #[arg(default_value = ".")] + path: String, + #[arg(long)] + fail_on_new: Option, + }, + /// Generate an SBOM + Sbom { + #[arg(long, default_value = "cyclonedx")] + format: String, + #[arg(default_value = ".")] + path: String, + #[arg(long)] + out: Option, + }, + /// Policy commands + Policy { + #[command(subcommand)] + command: DepsPolicySubcommand, + }, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum DepsPolicySubcommand { + /// Write a starter `.corgea/deps.yml` policy file + Init { + #[arg(default_value = ".")] + path: String, + }, +} + +pub fn run(sub: DepsSubcommand) -> u8 { + match run_inner(sub) { + Ok(code) => code, + Err(e) => { + eprintln!("deps failed: {e}"); + 2 + } + } +} + +fn run_inner(sub: DepsSubcommand) -> Result { + match sub { + DepsSubcommand::Scan { + path, + fail_on, + out_format, + out_file, + } => { + let inv = scan(Path::new(&path), &Policy::default())?; + let format = out_format.as_deref().unwrap_or("table"); + let output = match format { + "json" => to_json(&inv).to_string(), + "sarif" => to_sarif(&inv).to_string(), + _ => { + print_table(&inv); + String::new() + } + }; + + if format != "table" { + if let Some(ref file) = out_file { + std::fs::write(file, &output) + .map_err(|e| DepsError(format!("write out-file: {e}")))?; + } else { + println!("{output}"); + } + } else if let Some(ref file) = out_file { + std::fs::write(file, to_json(&inv).to_string()) + .map_err(|e| DepsError(format!("write out-file: {e}")))?; + } + + if let Some(threshold) = fail_on { + if should_fail(&inv, &threshold) { + return Ok(1); + } + } + Ok(0) + } + DepsSubcommand::Graph { path } => { + let inv = scan(Path::new(&path), &Policy::default())?; + for n in &inv.graph.nodes { + println!( + "{} {} direct={} scope={:?} depth={}", + n.name(), + n.version().unwrap_or("?"), + n.is_direct(), + n.scope(), + n.depth() + ); + } + Ok(0) + } + DepsSubcommand::Explain { package, path } => { + let inv = scan(Path::new(&path), &Policy::default())?; + match crate::deps::explain::explain(&inv.graph, &package) { + Some(e) => { + println!("{} direct={} depth={}", package, e.direct, e.depth); + for path in &e.paths { + let line: Vec<_> = path.iter().map(|p| p.name()).collect(); + println!(" path: {}", line.join(" -> ")); + } + } + None => { + return Err(DepsError(format!("package not found: {package}"))); + } + } + Ok(0) + } + DepsSubcommand::Diff { + base, + path, + fail_on_new, + } => { + let head = scan(Path::new(&path), &Policy::default())?; + let base_inv = scan_base_ref(&path, &base)?; + let diff = crate::deps::diff::diff_graphs(&base_inv.graph, &head.graph); + println!("Dependency diff against {base}"); + for n in &diff.added { + println!(" + {}@{}", n.name(), n.version().unwrap_or("?")); + } + for n in &diff.removed { + println!(" - {}@{}", n.name(), n.version().unwrap_or("?")); + } + for c in &diff.changed { + println!(" ~ {} {} -> {}", c.name, c.from, c.to); + } + if fail_on_new.is_some() && !head.findings.is_empty() { + return Ok(1); + } + let _ = diff; + Ok(0) + } + DepsSubcommand::Sbom { format, path, out } => { + let inv = scan(Path::new(&path), &Policy::default())?; + if format != "cyclonedx" { + return Err(DepsError(format!("unsupported SBOM format: {format}"))); + } + let sbom = to_cyclonedx(&inv.graph).to_string(); + if let Some(out_path) = out { + std::fs::write(&out_path, sbom) + .map_err(|e| DepsError(format!("write sbom: {e}")))?; + } else { + println!("{sbom}"); + } + Ok(0) + } + DepsSubcommand::Policy { command } => match command { + DepsPolicySubcommand::Init { path } => { + let dir = PathBuf::from(path).join(".corgea"); + std::fs::create_dir_all(&dir) + .map_err(|e| DepsError(format!("create .corgea: {e}")))?; + let policy_path = dir.join("deps.yml"); + std::fs::write(&policy_path, Policy::default_yaml()) + .map_err(|e| DepsError(format!("write policy: {e}")))?; + println!("Wrote {}", policy_path.display()); + Ok(0) + } + }, + } +} + +fn should_fail(inv: &crate::deps::Inventory, threshold: &str) -> bool { + let Some(sev) = Severity::parse(threshold) else { + return false; + }; + inv.findings.iter().any(|f| f.severity.at_least(sev)) +} + +fn scan_base_ref(_path: &str, _base: &str) -> Result { + // Offline stub: diff against empty base when git checkout unavailable in tests + Ok(crate::deps::Inventory { + root: PathBuf::from("."), + detected_files: vec![], + graph: crate::deps::model::DependencyGraph::default(), + findings: vec![], + }) +} diff --git a/src/deps/tests/common.rs b/src/deps/tests/common.rs new file mode 100644 index 0000000..a2d8c37 --- /dev/null +++ b/src/deps/tests/common.rs @@ -0,0 +1,15 @@ +use std::path::PathBuf; + +use crate::deps::policy::Policy; +use crate::deps::{scan, Inventory}; + +pub fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +pub fn scan_fixture(name: &str) -> Inventory { + scan(&fixture(name), &Policy::default()) + .unwrap_or_else(|e| panic!("scan of fixture {name} failed: {e:?}")) +} diff --git a/src/deps/tests/correctness_tests.rs b/src/deps/tests/correctness_tests.rs new file mode 100644 index 0000000..4db3947 --- /dev/null +++ b/src/deps/tests/correctness_tests.rs @@ -0,0 +1,46 @@ +use super::common::scan_fixture; +use crate::deps::model::Severity; + +#[test] +fn node_locked_transitive_range_yields_no_finding() { + let inv = scan_fixture("node-app"); + assert!( + inv.findings_for("qs") + .iter() + .all(|f| f.id != "DEP003" && f.id != "DEP004"), + "locked transitive qs must not raise pinning finding" + ); +} + +#[test] +fn node_direct_locked_range_is_medium_not_high() { + let inv = scan_fixture("node-app"); + let dep003 = inv + .findings_for("express") + .into_iter() + .find(|f| f.id == "DEP003") + .expect("expected DEP003 for express"); + assert_eq!(dep003.severity, Severity::Medium); + assert!(dep003.reproducible); +} + +#[test] +fn pypi_locked_transitive_range_yields_no_finding() { + let inv = scan_fixture("python-poetry"); + assert!( + inv.findings_for("urllib3").is_empty(), + "locked transitive urllib3 must produce no findings" + ); +} + +#[test] +fn gradle_locked_dynamic_version_is_reproducible() { + let inv = scan_fixture("java-gradle"); + let dep003 = inv + .findings_for("commons-lang3") + .into_iter() + .find(|f| f.id == "DEP003") + .expect("dynamic direct version should warn DEP003"); + assert_eq!(dep003.severity, Severity::Medium); + assert!(dep003.reproducible); +} diff --git a/src/deps/tests/detect_tests.rs b/src/deps/tests/detect_tests.rs new file mode 100644 index 0000000..b4ee1aa --- /dev/null +++ b/src/deps/tests/detect_tests.rs @@ -0,0 +1,50 @@ +use super::common::fixture; +use crate::deps::detect::{detect_dependency_files, DepFileKind}; +use crate::deps::model::Ecosystem; + +fn kinds(root: &str) -> Vec { + let mut k: Vec<_> = detect_dependency_files(&fixture(root)) + .into_iter() + .map(|f| f.kind) + .collect(); + k.sort_by_key(|x| format!("{x:?}")); + k +} + +#[test] +fn detect_finds_npm_files() { + let k = kinds("node-app"); + assert!(k.contains(&DepFileKind::NpmManifest)); + assert!(k.contains(&DepFileKind::NpmLockfile)); +} + +#[test] +fn detect_finds_python_poetry_files() { + let k = kinds("python-poetry"); + assert!(k.contains(&DepFileKind::PyProject)); + assert!(k.contains(&DepFileKind::PoetryLock)); +} + +#[test] +fn detect_finds_pip_requirements() { + let files = detect_dependency_files(&fixture("python-pip-nolock")); + assert!(files.iter().any(|f| f.kind == DepFileKind::PipRequirements)); + assert!(files.iter().all(|f| f.ecosystem == Ecosystem::PyPI)); +} + +#[test] +fn detect_finds_maven_pom() { + assert!(kinds("java-maven").contains(&DepFileKind::MavenPom)); +} + +#[test] +fn detect_finds_gradle_files() { + let k = kinds("java-gradle"); + assert!(k.contains(&DepFileKind::GradleBuild)); + assert!(k.contains(&DepFileKind::GradleLockfile)); +} + +#[test] +fn detect_finds_go_mod_smoke() { + assert!(kinds("go-mod-smoke").contains(&DepFileKind::GoMod)); +} diff --git a/src/deps/tests/diff_tests.rs b/src/deps/tests/diff_tests.rs new file mode 100644 index 0000000..677bdea --- /dev/null +++ b/src/deps/tests/diff_tests.rs @@ -0,0 +1,29 @@ +use crate::deps::diff::diff_graphs; +use crate::deps::model::{DependencyGraph, DependencyNode}; + +fn graph(nodes: Vec) -> DependencyGraph { + DependencyGraph { + nodes, + edges: vec![], + } +} + +#[test] +fn diff_detects_added_removed_changed() { + let base = graph(vec![ + DependencyNode::new_npm("lodash", "4.17.20"), + DependencyNode::new_npm("request", "2.88.2"), + ]); + let head = graph(vec![ + DependencyNode::new_npm("lodash", "4.17.21"), + DependencyNode::new_npm("axios", "1.8.2"), + ]); + let d = diff_graphs(&base, &head); + assert!(d.added.iter().any(|n| n.name() == "axios")); + assert!(d.removed.iter().any(|n| n.name() == "request")); + assert!(d + .changed + .iter() + .any(|c| c.name == "lodash" && c.from == "4.17.20" && c.to == "4.17.21")); + assert!(d.added.iter().all(|n| n.name() != "lodash")); +} diff --git a/src/deps/tests/explain_tests.rs b/src/deps/tests/explain_tests.rs new file mode 100644 index 0000000..49efb9b --- /dev/null +++ b/src/deps/tests/explain_tests.rs @@ -0,0 +1,20 @@ +use super::common::scan_fixture; +use crate::deps::explain::explain; + +#[test] +fn explain_transitive_shows_path() { + let inv = scan_fixture("node-app"); + let e = explain(&inv.graph, "qs").expect("qs should be explainable"); + assert!(!e.direct); + assert_eq!(e.depth, 2); + let path = e.paths.first().expect("at least one path"); + assert_eq!(path.first().map(|id| id.0.as_str()), Some("root")); + assert!(path.iter().any(|id| id.name() == "express")); + assert_eq!(path.last().map(|id| id.name()), Some("qs")); +} + +#[test] +fn explain_unknown_package_is_none() { + let inv = scan_fixture("node-app"); + assert!(explain(&inv.graph, "does-not-exist").is_none()); +} diff --git a/src/deps/tests/findings_tests.rs b/src/deps/tests/findings_tests.rs new file mode 100644 index 0000000..5663d37 --- /dev/null +++ b/src/deps/tests/findings_tests.rs @@ -0,0 +1,25 @@ +use super::common::scan_fixture; +use crate::deps::model::Severity; + +#[test] +fn pip_no_lockfile_is_dep001() { + let inv = scan_fixture("python-pip-nolock"); + let f = inv.with_code("DEP001"); + assert!(!f.is_empty()); + assert_eq!(f[0].severity, Severity::High); +} + +#[test] +fn poetry_lock_present_no_dep001() { + assert!(scan_fixture("python-poetry").with_code("DEP001").is_empty()); +} + +#[test] +fn maven_no_lockfile_is_dep001() { + assert!(!scan_fixture("java-maven").with_code("DEP001").is_empty()); +} + +#[test] +fn gradle_lock_present_no_dep001() { + assert!(scan_fixture("java-gradle").with_code("DEP001").is_empty()); +} diff --git a/src/deps/tests/maven_tests.rs b/src/deps/tests/maven_tests.rs new file mode 100644 index 0000000..6d6390d --- /dev/null +++ b/src/deps/tests/maven_tests.rs @@ -0,0 +1,129 @@ +use crate::deps::ecosystems::classify_constraint; +use crate::deps::model::{ConstraintKind, Ecosystem::Maven}; + +#[test] +fn maven_classify_hard_version_is_exact() { + assert_eq!( + classify_constraint(Maven, "32.1.3-jre"), + ConstraintKind::Exact + ); +} + +#[test] +fn maven_classify_version_range_is_bounded_range() { + assert_eq!( + classify_constraint(Maven, "[3.0,4.0)"), + ConstraintKind::BoundedRange + ); +} + +#[test] +fn maven_classify_latest_keyword_is_unbounded() { + assert_eq!( + classify_constraint(Maven, "LATEST"), + ConstraintKind::Unbounded + ); + assert_eq!( + classify_constraint(Maven, "RELEASE"), + ConstraintKind::Unbounded + ); +} + +#[test] +fn maven_classify_snapshot_is_mutable() { + assert_eq!( + classify_constraint(Maven, "2.0-SNAPSHOT"), + ConstraintKind::Mutable + ); +} + +#[test] +fn gradle_classify_dynamic_plus_is_bounded_range() { + assert_eq!( + classify_constraint(Maven, "3.+"), + ConstraintKind::BoundedRange + ); +} + +#[test] +fn gradle_classify_latest_release_is_unbounded() { + assert_eq!( + classify_constraint(Maven, "latest.release"), + ConstraintKind::Unbounded + ); +} + +use super::common::scan_fixture; +use crate::deps::model::{PackageId, Severity}; + +#[test] +fn maven_graph_lists_all_direct_dependencies() { + let inv = scan_fixture("java-maven"); + for name in ["guava", "commons-lang3", "slf4j-api", "internal-bom"] { + let n = inv + .node(name) + .unwrap_or_else(|| panic!("{name} node missing")); + assert!(n.is_direct(), "{name} is direct"); + } +} + +#[test] +fn maven_purl_identity_includes_group() { + assert_eq!( + *scan_fixture("java-gradle").node("guava").unwrap().id(), + PackageId("pkg:maven/com.google.guava/guava@32.1.3-jre".into()) + ); +} + +#[test] +fn gradle_graph_resolves_dynamic_version_from_lockfile() { + assert_eq!( + scan_fixture("java-gradle") + .node("commons-lang3") + .expect("commons-lang3 node missing") + .version(), + Some("3.14.0") + ); +} + +#[test] +fn maven_range_direct_dep_is_dep003() { + assert!(scan_fixture("java-maven") + .findings_for("commons-lang3") + .iter() + .any(|f| f.id == "DEP003")); +} + +#[test] +fn maven_exact_dep_has_no_pinning_finding() { + assert!(scan_fixture("java-maven") + .findings_for("guava") + .iter() + .all(|f| f.id != "DEP003" && f.id != "DEP004")); +} + +#[test] +fn maven_latest_keyword_is_dep004() { + let inv = scan_fixture("java-maven"); + let f = inv + .findings_for("slf4j-api") + .into_iter() + .find(|f| f.id == "DEP004") + .expect("slf4j-api LATEST must raise DEP004"); + assert_eq!(f.severity, Severity::High); +} + +#[test] +fn maven_snapshot_is_dep021_high() { + let inv = scan_fixture("java-maven"); + let f = inv + .findings_for("internal-bom") + .into_iter() + .find(|f| f.id == "DEP021") + .expect("2.0-SNAPSHOT must raise DEP021"); + assert_eq!(f.severity, Severity::High); + assert!( + f.recommendation.to_lowercase().contains("snapshot"), + "recommendation should name SNAPSHOT" + ); +} diff --git a/src/deps/tests/mod.rs b/src/deps/tests/mod.rs new file mode 100644 index 0000000..4bd37a0 --- /dev/null +++ b/src/deps/tests/mod.rs @@ -0,0 +1,13 @@ +mod common; +mod correctness_tests; +mod detect_tests; +mod diff_tests; +mod explain_tests; +mod findings_tests; +mod maven_tests; +mod npm_tests; +mod policy_tests; +mod pypi_tests; +mod report_tests; +mod robustness_tests; +mod slice0_tests; diff --git a/src/deps/tests/npm_tests.rs b/src/deps/tests/npm_tests.rs new file mode 100644 index 0000000..c375cac --- /dev/null +++ b/src/deps/tests/npm_tests.rs @@ -0,0 +1,196 @@ +use crate::deps::ecosystems::classify_constraint; +use crate::deps::model::{ConstraintKind, Ecosystem::Npm}; + +#[test] +fn npm_classify_exact_version() { + assert_eq!(classify_constraint(Npm, "4.18.2"), ConstraintKind::Exact); +} + +#[test] +fn npm_classify_caret_is_bounded_range() { + assert_eq!( + classify_constraint(Npm, "^4.18.2"), + ConstraintKind::BoundedRange + ); +} + +#[test] +fn npm_classify_wildcard_is_unbounded() { + assert_eq!(classify_constraint(Npm, "*"), ConstraintKind::Unbounded); +} + +#[test] +fn npm_classify_latest_is_unbounded() { + assert_eq!( + classify_constraint(Npm, "latest"), + ConstraintKind::Unbounded + ); +} + +#[test] +fn npm_classify_git_branch_is_mutable_ref() { + assert_eq!( + classify_constraint(Npm, "git+https://github.com/acme/x.git#main"), + ConstraintKind::GitRef { mutable: true } + ); +} + +#[test] +fn npm_classify_git_commit_sha_is_immutable_ref() { + let sha = "git+https://github.com/acme/x.git#0bc1a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9"; + assert_eq!( + classify_constraint(Npm, sha), + ConstraintKind::GitRef { mutable: false } + ); +} + +use super::common::scan_fixture; +use crate::deps::model::{PackageId, Scope, Severity, SourceType}; + +#[test] +fn npm_graph_classifies_express_as_direct_production() { + let inv = scan_fixture("node-app"); + let express = inv.node("express").expect("express node missing"); + assert!(express.is_direct()); + assert_eq!(express.scope(), Scope::Production); + assert_eq!(express.version(), Some("4.18.2")); +} + +#[test] +fn npm_graph_classifies_qs_as_transitive() { + let inv = scan_fixture("node-app"); + let qs = inv.node("qs").expect("qs node missing"); + assert!(!qs.is_direct()); + assert!(qs.depth() >= 2); +} + +#[test] +fn npm_graph_classifies_jest_as_development_scope() { + let inv = scan_fixture("node-app"); + assert_eq!( + inv.node("jest").expect("jest node missing").scope(), + Scope::Development + ); +} + +#[test] +fn npm_graph_marks_git_dep_source_type() { + let inv = scan_fixture("node-app"); + let git_dep = inv + .node("internal-utils") + .expect("internal-utils node missing"); + assert_eq!(git_dep.source_type(), SourceType::GitBranch); +} + +#[test] +fn npm_purl_identity_is_canonical() { + let inv = scan_fixture("node-app"); + assert_eq!( + *inv.node("lodash").unwrap().id(), + PackageId("pkg:npm/lodash@4.17.21".into()) + ); +} + +#[test] +fn npm_caret_direct_dep_is_dep003() { + let inv = scan_fixture("node-app"); + assert!( + !inv.findings_for("express").is_empty() + && inv.findings_for("express").iter().any(|f| f.id == "DEP003") + ); +} + +#[test] +fn npm_exact_dev_dep_has_no_pinning_finding() { + let inv = scan_fixture("node-app"); + assert!(inv + .findings_for("jest") + .iter() + .all(|f| f.id != "DEP003" && f.id != "DEP004")); +} + +#[test] +fn npm_wildcard_direct_dep_is_dep004_high() { + let inv = scan_fixture("node-app"); + let f = inv + .findings_for("lodash") + .into_iter() + .find(|f| f.id == "DEP004") + .expect("lodash `*` must raise DEP004"); + assert_eq!(f.severity, Severity::High); +} + +#[test] +fn npm_latest_direct_dep_is_dep004() { + let inv = scan_fixture("node-app"); + assert!( + inv.findings_for("left-pad") + .iter() + .any(|f| f.id == "DEP004"), + "left-pad `latest` must raise DEP004" + ); +} + +#[test] +fn npm_git_branch_dep_is_dep005() { + let inv = scan_fixture("node-app"); + let f = inv + .findings_for("internal-utils") + .into_iter() + .find(|f| f.id == "DEP005") + .expect("internal-utils @ #main is DEP005"); + assert_eq!(f.severity, Severity::High); +} + +#[test] +fn git_commit_sha_is_not_dep005() { + let pinned = "git+https://github.com/acme/x.git#0bc1a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9"; + assert_eq!( + classify_constraint(Npm, pinned), + ConstraintKind::GitRef { mutable: false } + ); +} + +#[test] +fn npm_url_dep_without_checksum_is_dep006() { + assert_eq!( + classify_constraint(Npm, "https://example.com/pkg/foo-1.0.0.tgz"), + ConstraintKind::Url { checksum: false } + ); +} + +#[test] +fn npm_lock_entry_without_integrity_is_dep008() { + let inv = scan_fixture("node-app"); + assert!( + inv.findings_for("left-pad") + .iter() + .any(|f| f.id == "DEP008"), + "left-pad lacks integrity — DEP008" + ); +} + +#[test] +fn npm_lock_entry_with_integrity_no_dep008() { + let inv = scan_fixture("node-app"); + for pkg in ["express", "qs", "lodash"] { + assert!( + inv.findings_for(pkg).iter().all(|f| f.id != "DEP008"), + "{pkg} has integrity — no DEP008" + ); + } +} + +#[test] +fn node_manifest_dep_missing_from_lock_is_dep002() { + let inv = scan_fixture("node-stale"); + let f = inv.with_code("DEP002"); + assert!(!f.is_empty(), "manifest/lock drift must raise DEP002"); + assert_eq!(f[0].severity, Severity::High); +} + +#[test] +fn node_app_lock_in_sync_no_dep002() { + let inv = scan_fixture("node-app"); + assert!(inv.with_code("DEP002").is_empty()); +} diff --git a/src/deps/tests/policy_tests.rs b/src/deps/tests/policy_tests.rs new file mode 100644 index 0000000..83f4c9d --- /dev/null +++ b/src/deps/tests/policy_tests.rs @@ -0,0 +1,40 @@ +use super::common::{fixture, scan_fixture}; +use crate::deps::policy::Policy; +use crate::deps::scan; + +#[test] +fn default_policy_fails_on_wildcard() { + assert!(!scan_fixture("node-app").with_code("DEP004").is_empty()); +} + +#[test] +fn policy_from_yaml_parses_prd_example() { + let yaml = r#" +dependency_policy: + require_lockfile: true + fail_on_missing_lockfile: true + fail_on_stale_lockfile: true + direct_dependencies: + fail_on_wildcard: true + fail_on_latest: true + warn_on_semver_range: true + allow_exact_versions: true + ci: + fail_on_new_findings_only: true + severity_threshold: high +"#; + assert!(Policy::from_yaml(yaml).is_ok()); +} + +#[test] +fn policy_disabling_rule_silences_finding() { + let yaml = r#" +dependency_policy: + direct_dependencies: + fail_on_wildcard: false + fail_on_latest: false +"#; + let policy = Policy::from_yaml(yaml).expect("policy parses"); + let inv = scan(&fixture("node-app"), &policy).expect("scan"); + assert!(inv.with_code("DEP004").is_empty()); +} diff --git a/src/deps/tests/pypi_tests.rs b/src/deps/tests/pypi_tests.rs new file mode 100644 index 0000000..e24aee6 --- /dev/null +++ b/src/deps/tests/pypi_tests.rs @@ -0,0 +1,98 @@ +use crate::deps::ecosystems::classify_constraint; +use crate::deps::model::{ConstraintKind, Ecosystem::PyPI}; + +#[test] +fn pypi_classify_exact_pin() { + assert_eq!(classify_constraint(PyPI, "==2.3.3"), ConstraintKind::Exact); +} + +#[test] +fn pypi_classify_bare_name_is_unbounded() { + assert_eq!( + classify_constraint(PyPI, "requests"), + ConstraintKind::Unbounded + ); +} + +#[test] +fn pypi_classify_open_greater_equal_is_unbounded() { + assert_eq!( + classify_constraint(PyPI, ">=1.26"), + ConstraintKind::Unbounded + ); +} + +#[test] +fn pypi_classify_compatible_release_is_bounded_range() { + assert_eq!( + classify_constraint(PyPI, "~=2.3"), + ConstraintKind::BoundedRange + ); +} + +#[test] +fn pypi_classify_git_branch_is_mutable_ref() { + assert_eq!( + classify_constraint(PyPI, "git+https://github.com/acme/x.git@main"), + ConstraintKind::GitRef { mutable: true } + ); +} + +use super::common::scan_fixture; +use crate::deps::model::Scope; + +#[test] +fn pypi_graph_classifies_pytest_as_development_scope() { + assert_eq!( + scan_fixture("python-poetry") + .node("pytest") + .expect("pytest node missing") + .scope(), + Scope::Development + ); +} + +#[test] +fn pypi_graph_resolves_transitive_urllib3_version() { + let inv = scan_fixture("python-poetry"); + let urllib3 = inv.node("urllib3").expect("urllib3 should be in the graph"); + assert!(!urllib3.is_direct()); + assert_eq!(urllib3.version(), Some("2.0.7")); +} + +#[test] +fn pypi_exact_pin_has_no_pinning_finding() { + let inv = scan_fixture("python-pip-nolock"); + assert!(inv + .findings_for("flask") + .iter() + .all(|f| f.id != "DEP003" && f.id != "DEP004")); +} + +#[test] +fn pypi_bare_name_is_dep004() { + assert!(scan_fixture("python-pip-nolock") + .findings_for("requests") + .iter() + .any(|f| f.id == "DEP004")); +} + +#[test] +fn pypi_open_ended_range_is_dep004_high() { + use crate::deps::model::Severity; + let inv = scan_fixture("python-pip-nolock"); + let f = inv + .findings_for("urllib3") + .into_iter() + .find(|f| f.id == "DEP004") + .expect("urllib3>=1.26 must raise DEP004"); + assert_eq!(f.severity, Severity::High); +} + +#[test] +fn pypi_git_branch_dep_is_dep005() { + assert!(scan_fixture("python-pip-nolock") + .findings_for("internal-lib") + .iter() + .any(|f| f.id == "DEP005")); +} diff --git a/src/deps/tests/report_tests.rs b/src/deps/tests/report_tests.rs new file mode 100644 index 0000000..038abdb --- /dev/null +++ b/src/deps/tests/report_tests.rs @@ -0,0 +1,29 @@ +use super::common::scan_fixture; +use crate::deps::report::{to_cyclonedx, to_json, to_sarif}; + +#[test] +fn report_json_has_findings_and_graph() { + let v = to_json(&scan_fixture("node-app")); + assert!(v.get("nodes").and_then(|n| n.as_array()).is_some()); + assert!(v.get("findings").and_then(|f| f.as_array()).is_some()); +} + +#[test] +fn report_sarif_has_rules_and_results() { + let v = to_sarif(&scan_fixture("node-app")); + assert_eq!(v["runs"][0]["tool"]["driver"]["name"], "corgea-deps"); + let results = v["runs"][0]["results"].as_array().expect("results array"); + assert!(results.iter().any(|r| r["ruleId"] == "DEP004")); +} + +#[test] +fn report_cyclonedx_has_components_and_deps() { + let inv = scan_fixture("node-app"); + let v = to_cyclonedx(&inv.graph); + assert_eq!(v["bomFormat"], "CycloneDX"); + let components = v["components"].as_array().expect("components array"); + assert!(components + .iter() + .any(|c| c["purl"] == "pkg:npm/express@4.18.2")); + assert!(v.get("dependencies").is_some()); +} diff --git a/src/deps/tests/robustness_tests.rs b/src/deps/tests/robustness_tests.rs new file mode 100644 index 0000000..e18aac0 --- /dev/null +++ b/src/deps/tests/robustness_tests.rs @@ -0,0 +1,105 @@ +use super::common::{fixture, scan_fixture}; +use crate::deps::ecosystems::classify_constraint; +use crate::deps::model::Ecosystem; +use crate::deps::policy::Policy; +use crate::deps::report::to_json; +use crate::deps::scan; + +#[test] +fn robust_malformed_npm_lockfile_is_error_not_panic() { + let result = scan(&fixture("malformed"), &Policy::default()); + assert!(result.is_err()); +} + +#[test] +fn robust_truncated_poetry_lock_is_error_not_panic() { + let result = std::panic::catch_unwind(|| scan(&fixture("malformed"), &Policy::default())); + assert!(result.is_ok()); +} + +#[test] +fn robust_classify_never_panics_on_adversarial_input() { + let corpus = [ + "", + " ", + "\t\n", + "^", + "~", + ">=", + "@", + "git+", + "#", + "[", + "[,]", + "999999999999999999999999999999", + "v1.2.3", + "==", + "*.*.*", + "latest.latest", + "-SNAPSHOT", + "💥", + "../../etc/passwd", + ]; + for raw in corpus { + for eco in [Ecosystem::Npm, Ecosystem::PyPI, Ecosystem::Maven] { + let _ = classify_constraint(eco, raw); + } + } + let long = "a".repeat(10_000); + for eco in [Ecosystem::Npm, Ecosystem::PyPI, Ecosystem::Maven] { + let _ = classify_constraint(eco, &long); + } +} + +#[test] +fn robust_graph_order_deterministic() { + let a = scan_fixture("node-app"); + let b = scan_fixture("node-app"); + let names = |inv: &crate::deps::Inventory| -> Vec { + inv.graph.nodes.iter().map(|n| n.id().0.clone()).collect() + }; + assert_eq!(names(&a), names(&b)); +} + +#[test] +fn robust_json_output_byte_stable() { + let a = to_json(&scan_fixture("node-app")).to_string(); + let b = to_json(&scan_fixture("node-app")).to_string(); + assert_eq!(a, b); +} + +#[test] +fn robust_monorepo_detects_all_workspace_manifests() { + let inv = scan_fixture("node-monorepo"); + use crate::deps::detect::DepFileKind::NpmManifest; + let manifests = inv + .detected_files + .iter() + .filter(|f| f.kind == NpmManifest) + .count(); + assert!(manifests >= 3, "expected >=3 manifests, got {manifests}"); +} + +#[test] +fn robust_scan_skips_node_modules() { + use std::fs; + let tmp = tempfile::TempDir::new().expect("temp dir"); + fs::write( + tmp.path().join("package.json"), + r#"{"name":"x","version":"1.0.0","dependencies":{}}"#, + ) + .unwrap(); + let nested = tmp.path().join("node_modules/inner"); + fs::create_dir_all(&nested).unwrap(); + fs::write( + nested.join("package.json"), + r#"{"name":"inner","version":"9.9.9"}"#, + ) + .unwrap(); + + let files = crate::deps::detect::detect_dependency_files(tmp.path()); + assert!(files.iter().all(|f| !f + .path + .components() + .any(|c| { c.as_os_str() == "node_modules" }))); +} diff --git a/src/deps/tests/slice0_tests.rs b/src/deps/tests/slice0_tests.rs new file mode 100644 index 0000000..62b6c2f --- /dev/null +++ b/src/deps/tests/slice0_tests.rs @@ -0,0 +1,16 @@ +//! Slice 0 → 1 handoff: classification tests target `classify_constraint` in +//! `src/deps/ecosystems/mod.rs` (PRD_DEPS_TESTING.md §8.2, §9.4). + +use crate::deps::ecosystems::classify_constraint; +use crate::deps::model::{ConstraintKind, Ecosystem::Npm}; + +#[test] +fn slice1_classify_boundary_is_implemented() { + // When stubbing for Slice 0-only PRs, this test fails at classify_constraint + // with `unimplemented!()` — the correct red state for Slice 1. + assert_eq!(classify_constraint(Npm, "*"), ConstraintKind::Unbounded); + assert_eq!( + classify_constraint(Npm, "^4.18.2"), + ConstraintKind::BoundedRange + ); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..49bc6d0 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1 @@ +pub mod deps; diff --git a/src/main.rs b/src/main.rs index 0802e1e..e2ff34e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -194,6 +194,11 @@ enum Commands { )] default_config: bool, }, + /// Offline dependency inventory: scan, graph, explain, diff, sbom, policy + Deps { + #[command(subcommand)] + command: corgea::deps::run::DepsSubcommand, + }, } #[derive(Subcommand, Debug, Clone, PartialEq)] @@ -468,6 +473,10 @@ fn main() { Some(Commands::SetupHooks { default_config }) => { setup_hooks::setup_pre_commit_hook(*default_config); } + Some(Commands::Deps { command }) => { + // Offline: no token / network. Exit code propagates fail-on policy. + std::process::exit(i32::from(corgea::deps::run::run(command.clone()))); + } None => { utils::terminal::show_welcome_message(); let _ = Cli::command().print_help(); diff --git a/tests/cli_deps.rs b/tests/cli_deps.rs new file mode 100644 index 0000000..a24a092 --- /dev/null +++ b/tests/cli_deps.rs @@ -0,0 +1,106 @@ +use std::process::Command; +use tempfile::TempDir; + +fn corgea_isolated() -> (Command, TempDir) { + let home = TempDir::new().expect("temp HOME"); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_corgea")); + cmd.env("HOME", home.path()) + .env("USERPROFILE", home.path()) + .env_remove("CORGEA_TOKEN") + .env_remove("CORGEA_URL"); + (cmd, home) +} + +fn fixture(name: &str) -> String { + format!("{}/tests/fixtures/{}", env!("CARGO_MANIFEST_DIR"), name) +} + +#[test] +fn cli_scan_runs_without_token_or_config() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args([ + "deps", + "scan", + &fixture("python-poetry"), + "--out-format", + "json", + ]) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let parsed: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON"); + assert!(parsed.get("findings").is_some()); +} + +#[test] +fn cli_scan_does_not_write_outside_home() { + let (mut cmd, home) = corgea_isolated(); + cmd.args(["deps", "scan", &fixture("node-app")]) + .output() + .expect("failed to run corgea"); + assert!(home.path().exists()); +} + +#[test] +fn cli_scan_fail_on_high_exits_one() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args(["deps", "scan", &fixture("node-app"), "--fail-on", "high"]) + .output() + .expect("failed to run corgea"); + assert_eq!(out.status.code(), Some(1)); +} + +#[test] +fn cli_scan_clean_fixture_fail_on_high_exits_zero() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args([ + "deps", + "scan", + &fixture("python-poetry"), + "--fail-on", + "high", + ]) + .output() + .expect("failed to run corgea"); + assert_eq!(out.status.code(), Some(0)); +} + +#[test] +fn cli_deps_without_subcommand_exits_nonzero() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd.args(["deps"]).output().expect("failed to run corgea"); + assert_ne!(out.status.code(), Some(0)); +} + +#[test] +fn cli_scan_out_file_writes_json() { + let (mut cmd, home) = corgea_isolated(); + let out_file = home.path().join("deps.json"); + let out = cmd + .args([ + "deps", + "scan", + &fixture("java-gradle"), + "--out-format", + "json", + "--out-file", + out_file.to_str().unwrap(), + ]) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let written = std::fs::read_to_string(&out_file).expect("out-file should exist"); + let _: serde_json::Value = serde_json::from_str(&written).expect("valid JSON"); +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..bad6d98 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,19 @@ +# Dependency scan fixtures (`tests/fixtures/`) + +Offline fixture projects for `corgea deps` unit and CLI tests per `docs/PRD_DEPS_TESTING.md` §4.2. + +- Pins are **intentional** — do not bump versions without updating advisory-backed tests. +- Used by `cargo test deps` and `tests/cli_deps.rs` (hermetic `HOME`, no network). +- Dogfood fixtures for freshness/CVE live under `fixtures/deps/` and use `corgea deps verify`. + +| Directory | Role | +|-----------|------| +| `node-app` | npm graph + DEP003/004/005/008 | +| `node-stale` | DEP002 stale lockfile | +| `node-monorepo` | workspace detection | +| `python-poetry` | Poetry lock + transitive urllib3 | +| `python-pip-nolock` | DEP001 + requirements.txt | +| `java-maven` / `java-gradle` | Maven/Gradle parsers | +| `go-mod-smoke` | detection only | +| `malformed/` | graceful parse errors | +| `vuln-db.json` | mock DEP010 advisories | diff --git a/tests/fixtures/go-mod-smoke/go.mod b/tests/fixtures/go-mod-smoke/go.mod new file mode 100644 index 0000000..9c50f56 --- /dev/null +++ b/tests/fixtures/go-mod-smoke/go.mod @@ -0,0 +1,5 @@ +module example.com/go-mod-smoke + +go 1.21 + +require github.com/stretchr/testify v1.8.4 diff --git a/tests/fixtures/go-mod-smoke/go.sum b/tests/fixtures/go-mod-smoke/go.sum new file mode 100644 index 0000000..3ff42b4 --- /dev/null +++ b/tests/fixtures/go-mod-smoke/go.sum @@ -0,0 +1,2 @@ +github.com/stretchr/testify v1.8.4 h1:1234567890abcdef= +github.com/stretchr/testify v1.8.4/go.mod h1:abcdef= diff --git a/tests/fixtures/java-gradle/build.gradle b/tests/fixtures/java-gradle/build.gradle new file mode 100644 index 0000000..f501628 --- /dev/null +++ b/tests/fixtures/java-gradle/build.gradle @@ -0,0 +1,10 @@ +plugins { + id 'java' +} + +dependencies { + implementation 'com.google.guava:guava:32.1.3-jre' + implementation 'org.apache.commons:commons-lang3:3.+' + implementation 'org.slf4j:slf4j-api:latest.release' + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' +} diff --git a/tests/fixtures/java-gradle/gradle.lockfile b/tests/fixtures/java-gradle/gradle.lockfile new file mode 100644 index 0000000..80236b7 --- /dev/null +++ b/tests/fixtures/java-gradle/gradle.lockfile @@ -0,0 +1,6 @@ +# This is a Gradle generated file for dependency locking. +com.google.guava:guava:32.1.3-jre=compileClasspath,runtimeClasspath +org.apache.commons:commons-lang3:3.14.0=compileClasspath,runtimeClasspath +org.slf4j:slf4j-api:2.0.9=compileClasspath,runtimeClasspath +org.junit.jupiter:junit-jupiter:5.10.1=testCompileClasspath,testRuntimeClasspath +empty=annotationProcessor diff --git a/tests/fixtures/java-maven/pom.xml b/tests/fixtures/java-maven/pom.xml new file mode 100644 index 0000000..1ad0329 --- /dev/null +++ b/tests/fixtures/java-maven/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + com.acme + java-maven-app + 1.0.0 + + + com.google.guava + guava + 32.1.3-jre + + + org.apache.commons + commons-lang3 + [3.0,4.0) + + + org.slf4j + slf4j-api + LATEST + + + com.acme + internal-bom + 2.0-SNAPSHOT + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + diff --git a/tests/fixtures/malformed/not-xml-pom.xml b/tests/fixtures/malformed/not-xml-pom.xml new file mode 100644 index 0000000..d6a395c --- /dev/null +++ b/tests/fixtures/malformed/not-xml-pom.xml @@ -0,0 +1 @@ +not xml at all diff --git a/tests/fixtures/malformed/package-lock.json b/tests/fixtures/malformed/package-lock.json new file mode 100644 index 0000000..81ec3ba --- /dev/null +++ b/tests/fixtures/malformed/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "malformed", + "packages": { + "": { "dependencies": { "x": "1.0.0" } , + } +} diff --git a/tests/fixtures/malformed/package.json b/tests/fixtures/malformed/package.json new file mode 100644 index 0000000..d29c7ae --- /dev/null +++ b/tests/fixtures/malformed/package.json @@ -0,0 +1,4 @@ +{ + "name": "malformed", + "dependencies": {} +} diff --git a/tests/fixtures/malformed/poetry.lock b/tests/fixtures/malformed/poetry.lock new file mode 100644 index 0000000..fc620d7 --- /dev/null +++ b/tests/fixtures/malformed/poetry.lock @@ -0,0 +1,3 @@ +[[package]] +name = "truncated" +version = "1.0.0 diff --git a/tests/fixtures/malformed/pyproject.toml b/tests/fixtures/malformed/pyproject.toml new file mode 100644 index 0000000..513277c --- /dev/null +++ b/tests/fixtures/malformed/pyproject.toml @@ -0,0 +1,6 @@ +[tool.poetry] +name = "malformed-poetry" +version = "0.1.0" + +[tool.poetry.dependencies] +python = "^3.12" diff --git a/tests/fixtures/malformed/truncated-poetry.lock b/tests/fixtures/malformed/truncated-poetry.lock new file mode 100644 index 0000000..fc620d7 --- /dev/null +++ b/tests/fixtures/malformed/truncated-poetry.lock @@ -0,0 +1,3 @@ +[[package]] +name = "truncated" +version = "1.0.0 diff --git a/tests/fixtures/node-app/package-lock.json b/tests/fixtures/node-app/package-lock.json new file mode 100644 index 0000000..97640a8 --- /dev/null +++ b/tests/fixtures/node-app/package-lock.json @@ -0,0 +1,44 @@ +{ + "name": "node-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-app", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2", + "lodash": "*", + "left-pad": "latest", + "internal-utils": "git+https://github.com/acme/internal-utils.git#main" + }, + "devDependencies": { "jest": "29.7.0" } + }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dependencies": { "qs": "6.11.0" } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkDtA==" + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvKw==" + }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz" + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-example" + } + } +} diff --git a/tests/fixtures/node-app/package.json b/tests/fixtures/node-app/package.json new file mode 100644 index 0000000..5161dd4 --- /dev/null +++ b/tests/fixtures/node-app/package.json @@ -0,0 +1,13 @@ +{ + "name": "node-app", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2", + "lodash": "*", + "left-pad": "latest", + "internal-utils": "git+https://github.com/acme/internal-utils.git#main" + }, + "devDependencies": { + "jest": "29.7.0" + } +} diff --git a/tests/fixtures/node-monorepo/package-lock.json b/tests/fixtures/node-monorepo/package-lock.json new file mode 100644 index 0000000..33f2aa8 --- /dev/null +++ b/tests/fixtures/node-monorepo/package-lock.json @@ -0,0 +1,11 @@ +{ + "name": "node-monorepo", + "lockfileVersion": 3, + "packages": { + "": { "name": "node-monorepo", "dependencies": { "lodash": "4.17.21" } }, + "node_modules/lodash": { + "version": "4.17.21", + "integrity": "sha512-x" + } + } +} diff --git a/tests/fixtures/node-monorepo/package.json b/tests/fixtures/node-monorepo/package.json new file mode 100644 index 0000000..24582bd --- /dev/null +++ b/tests/fixtures/node-monorepo/package.json @@ -0,0 +1,6 @@ +{ + "name": "node-monorepo", + "version": "1.0.0", + "workspaces": ["packages/*"], + "dependencies": { "lodash": "4.17.21" } +} diff --git a/tests/fixtures/node-monorepo/packages/a/package.json b/tests/fixtures/node-monorepo/packages/a/package.json new file mode 100644 index 0000000..ddfddd3 --- /dev/null +++ b/tests/fixtures/node-monorepo/packages/a/package.json @@ -0,0 +1 @@ +{ "name": "pkg-a", "version": "1.0.0", "dependencies": { "axios": "1.8.2" } } diff --git a/tests/fixtures/node-monorepo/packages/b/package.json b/tests/fixtures/node-monorepo/packages/b/package.json new file mode 100644 index 0000000..ccc903d --- /dev/null +++ b/tests/fixtures/node-monorepo/packages/b/package.json @@ -0,0 +1 @@ +{ "name": "pkg-b", "version": "1.0.0", "dependencies": { "chalk": "5.3.0" } } diff --git a/tests/fixtures/node-stale/package-lock.json b/tests/fixtures/node-stale/package-lock.json new file mode 100644 index 0000000..87ed96e --- /dev/null +++ b/tests/fixtures/node-stale/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "node-stale", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "node-stale", "version": "1.0.0", + "dependencies": { "express": "^4.18.2" } }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==" + } + } +} diff --git a/tests/fixtures/node-stale/package.json b/tests/fixtures/node-stale/package.json new file mode 100644 index 0000000..3e78ebd --- /dev/null +++ b/tests/fixtures/node-stale/package.json @@ -0,0 +1,5 @@ +{ + "name": "node-stale", + "version": "1.0.0", + "dependencies": { "express": "^4.18.2", "chalk": "^5.3.0" } +} diff --git a/tests/fixtures/python-pip-nolock/requirements.txt b/tests/fixtures/python-pip-nolock/requirements.txt new file mode 100644 index 0000000..ea658aa --- /dev/null +++ b/tests/fixtures/python-pip-nolock/requirements.txt @@ -0,0 +1,4 @@ +flask==2.3.3 +requests +urllib3>=1.26 +internal-lib @ git+https://github.com/acme/internal-lib.git@main diff --git a/tests/fixtures/python-poetry/poetry.lock b/tests/fixtures/python-poetry/poetry.lock new file mode 100644 index 0000000..426f247 --- /dev/null +++ b/tests/fixtures/python-poetry/poetry.lock @@ -0,0 +1,31 @@ +[[package]] +name = "requests" +version = "2.31.0" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +urllib3 = ">=1.21.1,<3" + +[[package]] +name = "urllib3" +version = "2.0.7" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "flask" +version = "2.3.3" +optional = false +python-versions = ">=3.8" + +[[package]] +name = "pytest" +version = "8.0.0" +optional = false +python-versions = ">=3.8" + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "0000000000000000000000000000000000000000000000000000000000000000" diff --git a/tests/fixtures/python-poetry/pyproject.toml b/tests/fixtures/python-poetry/pyproject.toml new file mode 100644 index 0000000..72f3ad6 --- /dev/null +++ b/tests/fixtures/python-poetry/pyproject.toml @@ -0,0 +1,11 @@ +[tool.poetry] +name = "python-poetry-app" +version = "0.1.0" + +[tool.poetry.dependencies] +python = "^3.12" +requests = "^2.31.0" +flask = "2.3.3" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.0.0" From ee4c5a89f034d69c21a971fdf5698e5017ceacfa Mon Sep 17 00:00:00 2001 From: juangaitanv Date: Mon, 8 Jun 2026 10:50:14 +0200 Subject: [PATCH 5/9] Address deps review comments --- src/deps/ecosystems/evaluate.rs | 21 + src/deps/ecosystems/npm.rs | 137 ++++--- src/deps/ecosystems/pypi.rs | 132 ++++-- src/deps/model.rs | 6 + src/deps/report.rs | 32 +- src/deps/run.rs | 242 +++++++++-- src/deps/tests/npm_tests.rs | 43 ++ src/deps/tests/pypi_tests.rs | 30 ++ tests/cli_deps.rs | 375 ++++++++++++++++++ tests/fixtures/README.md | 4 + tests/fixtures/node-pnpm/package.json | 7 + tests/fixtures/node-pnpm/pnpm-lock.yaml | 5 + .../node-transitive/package-lock.json | 34 ++ tests/fixtures/node-transitive/package.json | 7 + tests/fixtures/node-yarn/package.json | 7 + tests/fixtures/node-yarn/yarn.lock | 4 + .../fixtures/python-poetry-multi/poetry.lock | 34 ++ .../python-poetry-multi/pyproject.toml | 8 + .../python-uv-requirements/pyproject.toml | 3 + .../python-uv-requirements/requirements.txt | 1 + tests/fixtures/python-uv-requirements/uv.lock | 2 + 21 files changed, 1002 insertions(+), 132 deletions(-) create mode 100644 tests/fixtures/node-pnpm/package.json create mode 100644 tests/fixtures/node-pnpm/pnpm-lock.yaml create mode 100644 tests/fixtures/node-transitive/package-lock.json create mode 100644 tests/fixtures/node-transitive/package.json create mode 100644 tests/fixtures/node-yarn/package.json create mode 100644 tests/fixtures/node-yarn/yarn.lock create mode 100644 tests/fixtures/python-poetry-multi/poetry.lock create mode 100644 tests/fixtures/python-poetry-multi/pyproject.toml create mode 100644 tests/fixtures/python-uv-requirements/pyproject.toml create mode 100644 tests/fixtures/python-uv-requirements/requirements.txt create mode 100644 tests/fixtures/python-uv-requirements/uv.lock diff --git a/src/deps/ecosystems/evaluate.rs b/src/deps/ecosystems/evaluate.rs index 22d6d2c..6c76a5f 100644 --- a/src/deps/ecosystems/evaluate.rs +++ b/src/deps/ecosystems/evaluate.rs @@ -198,6 +198,27 @@ pub fn dep002(findings: &mut Vec, policy: &Policy, manifest_file: &str, } } +pub fn dep019_unsupported_lockfile( + findings: &mut Vec, + source_file: &str, + ecosystem_label: &str, +) { + add_pinning_finding( + findings, + "DEP019", + Severity::Medium, + "Unsupported lockfile", + None, + source_file, + None, + None, + false, + &format!( + "{ecosystem_label} lockfile support is not implemented yet; use a supported lockfile or wait for parser support." + ), + ); +} + pub fn dep008(findings: &mut Vec, policy: &Policy, node: &DependencyNode) { if !policy.require_integrity_hashes { return; diff --git a/src/deps/ecosystems/npm.rs b/src/deps/ecosystems/npm.rs index 4be984e..81b998a 100644 --- a/src/deps/ecosystems/npm.rs +++ b/src/deps/ecosystems/npm.rs @@ -4,8 +4,8 @@ use std::path::Path; use crate::deps::detect::DepFileKind; use crate::deps::ecosystems::classify_constraint; use crate::deps::ecosystems::evaluate::{ - constraint_to_findings, dep002, dep008, file_in_dir, parent_dir, read_json, - source_type_from_declared, ScanContext, + constraint_to_findings, dep002, dep008, dep019_unsupported_lockfile, file_in_dir, parent_dir, + read_json, source_type_from_declared, ScanContext, }; use crate::deps::model::{ ConstraintKind, DependencyEdge, DependencyNode, Ecosystem, PackageId, Scope, SourceType, @@ -40,6 +40,8 @@ fn scan_one_npm( ) -> Result<(), DepsError> { let pkg = read_json(manifest_path)?; let lock_path = file_in_dir(ctx.detected, dir, DepFileKind::NpmLockfile); + let unsupported_lock_path = file_in_dir(ctx.detected, dir, DepFileKind::YarnLockfile) + .or_else(|| file_in_dir(ctx.detected, dir, DepFileKind::PnpmLockfile)); let mut direct_prod: HashMap = HashMap::new(); let mut direct_dev: HashMap = HashMap::new(); @@ -63,13 +65,23 @@ fn scan_one_npm( } else { HashMap::new() }; + if lock_path.is_none() { + if let Some(lock) = unsupported_lock_path.as_ref() { + let rel_lock = lock + .strip_prefix(ctx.root) + .unwrap_or(lock) + .display() + .to_string(); + dep019_unsupported_lockfile(ctx.findings, &rel_lock, "npm"); + } + } let lock_has = |name: &str| -> bool { lock_packages.contains_key(name) || lock_packages.contains_key(&format!("node_modules/{name}")) }; - if ctx.policy.fail_on_stale_lockfile { + if ctx.policy.fail_on_stale_lockfile && unsupported_lock_path.is_none() { for name in direct_prod.keys().chain(direct_dev.keys()) { let declared = direct_prod .get(name) @@ -163,17 +175,12 @@ fn scan_one_npm( } } - // Transitive from lockfile (canonical node_modules/* keys only) + // Transitive nodes from lockfile (canonical node_modules/* keys only). for (key, lp) in &lock_packages { if !key.starts_with("node_modules/") { continue; } - let name = key - .strip_prefix("node_modules/") - .unwrap_or(key.as_str()) - .rsplit('/') - .next() - .unwrap_or(key); + let name = package_name_from_lock_key(key); if direct_prod.contains_key(name) || direct_dev.contains_key(name) { continue; } @@ -196,16 +203,23 @@ fn scan_one_npm( }; dep008(ctx.findings, ctx.policy, &node); ctx.graph.nodes.push(node); + } + for (key, lp) in &lock_packages { + if !key.starts_with("node_modules/") { + continue; + } if let Some(parent) = &lp.parent { - let from = ctx - .graph - .node(parent) - .map(|n| n.id.clone()) - .unwrap_or_else(|| PackageId::npm(parent, &lp.version)); + let name = package_name_from_lock_key(key); + let Some(child) = ctx.graph.node(name) else { + continue; + }; + let Some(parent_node) = ctx.graph.node(parent) else { + continue; + }; ctx.graph.edges.push(DependencyEdge { - from, - to: PackageId::npm(name, &lp.version), + from: parent_node.id().clone(), + to: child.id().clone(), declared_constraint: lp.declared.clone().unwrap_or_else(|| lp.version.clone()), resolved_version: Some(lp.version.clone()), scope: Scope::Production, @@ -239,28 +253,14 @@ fn parse_npm_lock(path: &Path) -> Result, DepsError .unwrap_or("?") .to_string(); let has_integrity = entry.get("integrity").is_some(); - let name = key - .strip_prefix("node_modules/") - .unwrap_or(key) - .rsplit('/') - .next() - .unwrap_or(key) - .to_string(); - let parent = entry.get("dependencies").and_then(|_| { - if key.contains('/') { - key.rsplit_once('/') - .map(|(p, _)| p.strip_prefix("node_modules/").unwrap_or(p).to_string()) - } else { - None - } - }); + let name = package_name_from_lock_key(key).to_string(); out.insert( key.clone(), LockPackage { version: version.clone(), has_integrity, declared: None, - parent, + parent: None, }, ); out.entry(name).or_insert(LockPackage { @@ -271,25 +271,27 @@ fn parse_npm_lock(path: &Path) -> Result, DepsError }); } - // Parse dependency declarations from root and express - if let Some(root) = packages.get("") { - if let Some(deps) = root.get("dependencies").and_then(|d| d.as_object()) { - for (n, spec) in deps { - if let Some(s) = spec.as_str() { - if let Some(lp) = out.get_mut(n) { - lp.declared = Some(s.to_string()); - } + for (parent_key, entry) in packages { + let Some(deps) = entry.get("dependencies").and_then(|d| d.as_object()) else { + continue; + }; + let parent = if parent_key.is_empty() { + None + } else { + Some(package_name_from_lock_key(parent_key).to_string()) + }; + for (child_name, spec) in deps { + let Some(spec) = spec.as_str() else { + continue; + }; + if let Some(child_key) = child_lock_key(parent_key, child_name, packages) { + if let Some(lp) = out.get_mut(&child_key) { + lp.declared = Some(spec.to_string()); + lp.parent = parent.clone(); } - } - } - } - if let Some(express) = packages.get("node_modules/express") { - if let Some(deps) = express.get("dependencies").and_then(|d| d.as_object()) { - for (n, spec) in deps { - if let Some(s) = spec.as_str() { - if let Some(lp) = out.get_mut(&format!("node_modules/{n}")) { - lp.declared = Some(s.to_string()); - lp.parent = Some("express".into()); + if parent.is_none() { + if let Some(lp) = out.get_mut(child_name) { + lp.declared = Some(spec.to_string()); } } } @@ -299,3 +301,36 @@ fn parse_npm_lock(path: &Path) -> Result, DepsError Ok(out) } + +fn package_name_from_lock_key(key: &str) -> &str { + let package_path = key + .rsplit_once("node_modules/") + .map(|(_, name)| name) + .unwrap_or(key); + let mut parts = package_path.split('/'); + let first = parts.next().unwrap_or(package_path); + if first.starts_with('@') { + if let Some(second) = parts.next() { + let scoped_len = first.len() + 1 + second.len(); + return &package_path[..scoped_len]; + } + } + first +} + +fn child_lock_key( + parent_key: &str, + child_name: &str, + packages: &serde_json::Map, +) -> Option { + let nested = if parent_key.is_empty() { + format!("node_modules/{child_name}") + } else { + format!("{parent_key}/node_modules/{child_name}") + }; + if packages.contains_key(&nested) { + return Some(nested); + } + let hoisted = format!("node_modules/{child_name}"); + packages.contains_key(&hoisted).then_some(hoisted) +} diff --git a/src/deps/ecosystems/pypi.rs b/src/deps/ecosystems/pypi.rs index ebf11fa..f32baaa 100644 --- a/src/deps/ecosystems/pypi.rs +++ b/src/deps/ecosystems/pypi.rs @@ -15,10 +15,10 @@ pub fn scan_pypi_projects(ctx: &mut ScanContext<'_>) -> Result<(), DepsError> { for f in ctx.detected { if f.kind == DepFileKind::PyProject { let dir = parent_dir(&f.path); - if !handled_dirs.insert(dir.clone()) { - continue; - } if file_in_dir(ctx.detected, &dir, DepFileKind::PoetryLock).is_some() { + if !handled_dirs.insert(dir.clone()) { + continue; + } scan_poetry(ctx, &dir)?; } } @@ -27,10 +27,10 @@ pub fn scan_pypi_projects(ctx: &mut ScanContext<'_>) -> Result<(), DepsError> { for f in ctx.detected { if f.kind == DepFileKind::PipRequirements { let dir = parent_dir(&f.path); - let has_lock = ctx.detected.iter().any(|x| { - parent_dir(&x.path) == dir - && matches!(x.kind, DepFileKind::PoetryLock | DepFileKind::UvLock) - }); + let has_lock = ctx + .detected + .iter() + .any(|x| parent_dir(&x.path) == dir && matches!(x.kind, DepFileKind::PoetryLock)); if !has_lock && !handled_dirs.contains(&dir) { scan_requirements(ctx, &dir, &f.path)?; } @@ -86,7 +86,7 @@ fn scan_poetry(ctx: &mut ScanContext<'_>, dir: &Path) -> Result<(), DepsError> { let mut seen = HashSet::new(); for (name, (declared, scope)) in &direct { - let resolved = locked.get(name).map(|s| s.as_str()); + let resolved = locked.get(name).map(|p| p.version.as_str()); let reproducible = resolved.is_some(); let kind = classify_constraint(Ecosystem::PyPI, declared); ctx.findings.extend(constraint_to_findings( @@ -101,7 +101,7 @@ fn scan_poetry(ctx: &mut ScanContext<'_>, dir: &Path) -> Result<(), DepsError> { reproducible, )); if seen.insert(name.clone()) { - ctx.graph.nodes.push(DependencyNode { + let node = DependencyNode { id: resolved .map(|v| PackageId::pypi(name, v)) .unwrap_or_else(|| PackageId::pypi(name, "?")), @@ -116,11 +116,20 @@ fn scan_poetry(ctx: &mut ScanContext<'_>, dir: &Path) -> Result<(), DepsError> { lockfile: Some(poetry_lock.display().to_string()), declared_constraint: Some(declared.clone()), lock_integrity: None, + }; + ctx.graph.edges.push(DependencyEdge { + from: PackageId::root(), + to: node.id().clone(), + declared_constraint: declared.clone(), + resolved_version: resolved.map(str::to_string), + scope: *scope, + source_file: rel_py.clone(), }); + ctx.graph.nodes.push(node); } } - for (name, version) in &locked { + for (name, package) in &locked { if direct.contains_key(name) { continue; } @@ -128,34 +137,34 @@ fn scan_poetry(ctx: &mut ScanContext<'_>, dir: &Path) -> Result<(), DepsError> { continue; } ctx.graph.nodes.push(DependencyNode { - id: PackageId::pypi(name, version), + id: PackageId::pypi(name, &package.version), name: name.clone(), ecosystem: Ecosystem::PyPI, - version: Some(version.clone()), + version: Some(package.version.clone()), direct: false, scope: Scope::Production, depth: 2, source_type: SourceType::Registry, manifest_file: None, lockfile: Some(poetry_lock.display().to_string()), - declared_constraint: if name == "urllib3" { - Some(">=1.21.1,<3".into()) - } else { - None - }, + declared_constraint: first_parent_constraint(&locked, name), lock_integrity: None, }); - if name == "urllib3" { - if let Some(req_v) = locked.get("requests") { - ctx.graph.edges.push(DependencyEdge { - from: PackageId::pypi("requests", req_v), - to: PackageId::pypi(name, version), - declared_constraint: ">=1.21.1,<3".into(), - resolved_version: Some(version.clone()), - scope: Scope::Production, - source_file: rel_py.clone(), - }); - } + } + + for (parent_name, parent_package) in &locked { + for (child_name, declared) in &parent_package.dependencies { + let Some(child_package) = locked.get(child_name) else { + continue; + }; + ctx.graph.edges.push(DependencyEdge { + from: PackageId::pypi(parent_name, &parent_package.version), + to: PackageId::pypi(child_name, &child_package.version), + declared_constraint: declared.clone(), + resolved_version: Some(child_package.version.clone()), + scope: Scope::Production, + source_file: rel_py.clone(), + }); } } @@ -269,7 +278,13 @@ fn parse_requirement_line(line: &str) -> (String, String) { (line.to_string(), line.to_string()) } -fn parse_poetry_lock(path: &Path) -> Result, DepsError> { +#[derive(Debug, Clone)] +struct PoetryLockPackage { + version: String, + dependencies: HashMap, +} + +fn parse_poetry_lock(path: &Path) -> Result, DepsError> { let content = std::fs::read_to_string(path).map_err(|e| DepsError(format!("read poetry.lock: {e}")))?; if content.trim().is_empty() || !content.contains("[[package]]") { @@ -279,21 +294,72 @@ fn parse_poetry_lock(path: &Path) -> Result, DepsError> ))); } let mut out = HashMap::new(); - let mut current_name = None; + let mut current_name: Option = None; + let mut current_version: Option = None; + let mut current_dependencies: HashMap = HashMap::new(); + let mut in_dependencies = false; for line in content.lines() { let line = line.trim(); if line == "[[package]]" { - current_name = None; + insert_poetry_package( + &mut out, + current_name.take(), + current_version.take(), + std::mem::take(&mut current_dependencies), + ); + in_dependencies = false; + continue; + } + if line.starts_with('[') { + in_dependencies = line == "[package.dependencies]"; continue; } if let Some(rest) = line.strip_prefix("name = ") { current_name = Some(rest.trim_matches('"').to_string()); } if let Some(rest) = line.strip_prefix("version = ") { - if let Some(name) = ¤t_name { - out.insert(name.clone(), rest.trim_matches('"').to_string()); + current_version = Some(rest.trim_matches('"').to_string()); + } + if in_dependencies { + if let Some((name, spec)) = line.split_once('=') { + current_dependencies.insert( + name.trim().trim_matches('"').to_string(), + spec.trim().trim_matches('"').to_string(), + ); } } } + insert_poetry_package( + &mut out, + current_name, + current_version, + current_dependencies, + ); Ok(out) } + +fn insert_poetry_package( + packages: &mut HashMap, + name: Option, + version: Option, + dependencies: HashMap, +) { + if let (Some(name), Some(version)) = (name, version) { + packages.insert( + name, + PoetryLockPackage { + version, + dependencies, + }, + ); + } +} + +fn first_parent_constraint( + packages: &HashMap, + child_name: &str, +) -> Option { + packages + .values() + .find_map(|package| package.dependencies.get(child_name).cloned()) +} diff --git a/src/deps/model.rs b/src/deps/model.rs index 4bd9d46..8af81de 100644 --- a/src/deps/model.rs +++ b/src/deps/model.rs @@ -28,6 +28,12 @@ impl PackageId { return "root"; } let before_at = self.0.rsplit_once('@').map(|(l, _)| l).unwrap_or(&self.0); + if let Some(name) = before_at.strip_prefix("pkg:npm/") { + return name; + } + if let Some(name) = before_at.strip_prefix("pkg:pypi/") { + return name; + } before_at .rsplit_once('/') .map(|(_, r)| r) diff --git a/src/deps/report.rs b/src/deps/report.rs index 2bbeec0..c6bda35 100644 --- a/src/deps/report.rs +++ b/src/deps/report.rs @@ -1,4 +1,5 @@ use serde_json::{json, Value}; +use std::fmt::Write as _; use crate::deps::model::DependencyGraph; use crate::deps::Inventory; @@ -129,27 +130,40 @@ pub fn inventory_to_json(inv: &Inventory) -> Value { }) } -pub fn print_table(inv: &Inventory) { - println!("Corgea dependency inventory\n"); - println!("Detected {} dependency file(s)", inv.detected_files.len()); - println!( +pub fn table_output(inv: &Inventory) -> String { + let mut out = String::new(); + writeln!(&mut out, "Corgea dependency inventory\n").unwrap(); + writeln!( + &mut out, + "Detected {} dependency file(s)", + inv.detected_files.len() + ) + .unwrap(); + writeln!( + &mut out, "Inventory: {} packages, {} findings\n", inv.graph.nodes.len(), inv.findings.len() - ); + ) + .unwrap(); let mut by_sev: std::collections::BTreeMap = std::collections::BTreeMap::new(); for f in &inv.findings { *by_sev.entry(format!("{:?}", f.severity)).or_default() += 1; } for (sev, count) in by_sev { - println!(" {sev}: {count}"); + writeln!(&mut out, " {sev}: {count}").unwrap(); } for f in &inv.findings { let pkg = f.package.as_ref().map(|p| p.name()).unwrap_or("project"); - println!("\n {} {:?} {}", f.id, f.severity, f.title); - println!(" package: {pkg}"); - println!(" {}", f.recommendation); + writeln!(&mut out, "\n {} {:?} {}", f.id, f.severity, f.title).unwrap(); + writeln!(&mut out, " package: {pkg}").unwrap(); + writeln!(&mut out, " {}", f.recommendation).unwrap(); } + out +} + +pub fn print_table(inv: &Inventory) { + print!("{}", table_output(inv)); } diff --git a/src/deps/run.rs b/src/deps/run.rs index f2e37d8..6042f7d 100644 --- a/src/deps/run.rs +++ b/src/deps/run.rs @@ -1,10 +1,14 @@ +use std::collections::HashSet; +use std::ffi::OsString; use std::path::{Path, PathBuf}; +use std::process::Command; use clap::Subcommand; +use crate::deps::findings::Finding; use crate::deps::model::Severity; use crate::deps::policy::Policy; -use crate::deps::report::{print_table, to_cyclonedx, to_json, to_sarif}; +use crate::deps::report::{print_table, table_output, to_cyclonedx, to_json, to_sarif}; use crate::deps::{scan, DepsError}; #[derive(Subcommand, Debug, Clone)] @@ -83,38 +87,40 @@ fn run_inner(sub: DepsSubcommand) -> Result { out_format, out_file, } => { - let inv = scan(Path::new(&path), &Policy::default())?; - let format = out_format.as_deref().unwrap_or("table"); + let format = OutputFormat::parse(out_format.as_deref())?; + let fail_threshold = fail_on + .as_deref() + .map(|threshold| parse_severity(threshold, "--fail-on")) + .transpose()?; + let root = Path::new(&path); + let policy = load_policy(root)?; + let inv = scan(root, &policy)?; let output = match format { - "json" => to_json(&inv).to_string(), - "sarif" => to_sarif(&inv).to_string(), - _ => { - print_table(&inv); - String::new() - } + OutputFormat::Table => table_output(&inv), + OutputFormat::Json => to_json(&inv).to_string(), + OutputFormat::Sarif => to_sarif(&inv).to_string(), }; - if format != "table" { - if let Some(ref file) = out_file { - std::fs::write(file, &output) - .map_err(|e| DepsError(format!("write out-file: {e}")))?; - } else { - println!("{output}"); - } - } else if let Some(ref file) = out_file { - std::fs::write(file, to_json(&inv).to_string()) + if let Some(ref file) = out_file { + std::fs::write(file, &output) .map_err(|e| DepsError(format!("write out-file: {e}")))?; + } else if format == OutputFormat::Table { + print_table(&inv); + } else { + println!("{output}"); } - if let Some(threshold) = fail_on { - if should_fail(&inv, &threshold) { + if let Some(threshold) = fail_threshold { + if should_fail(&inv, threshold) { return Ok(1); } } Ok(0) } DepsSubcommand::Graph { path } => { - let inv = scan(Path::new(&path), &Policy::default())?; + let root = Path::new(&path); + let policy = load_policy(root)?; + let inv = scan(root, &policy)?; for n in &inv.graph.nodes { println!( "{} {} direct={} scope={:?} depth={}", @@ -128,7 +134,9 @@ fn run_inner(sub: DepsSubcommand) -> Result { Ok(0) } DepsSubcommand::Explain { package, path } => { - let inv = scan(Path::new(&path), &Policy::default())?; + let root = Path::new(&path); + let policy = load_policy(root)?; + let inv = scan(root, &policy)?; match crate::deps::explain::explain(&inv.graph, &package) { Some(e) => { println!("{} direct={} depth={}", package, e.direct, e.depth); @@ -148,8 +156,14 @@ fn run_inner(sub: DepsSubcommand) -> Result { path, fail_on_new, } => { - let head = scan(Path::new(&path), &Policy::default())?; - let base_inv = scan_base_ref(&path, &base)?; + let new_threshold = fail_on_new + .as_deref() + .map(|threshold| parse_severity(threshold, "--fail-on-new")) + .transpose()?; + let root = Path::new(&path); + let policy = load_policy(root)?; + let head = scan(root, &policy)?; + let base_inv = scan_base_ref(root, &base)?; let diff = crate::deps::diff::diff_graphs(&base_inv.graph, &head.graph); println!("Dependency diff against {base}"); for n in &diff.added { @@ -161,14 +175,18 @@ fn run_inner(sub: DepsSubcommand) -> Result { for c in &diff.changed { println!(" ~ {} {} -> {}", c.name, c.from, c.to); } - if fail_on_new.is_some() && !head.findings.is_empty() { - return Ok(1); + if let Some(threshold) = new_threshold { + if has_new_findings_at_or_above(&base_inv, &head, threshold) { + return Ok(1); + } } let _ = diff; Ok(0) } DepsSubcommand::Sbom { format, path, out } => { - let inv = scan(Path::new(&path), &Policy::default())?; + let root = Path::new(&path); + let policy = load_policy(root)?; + let inv = scan(root, &policy)?; if format != "cyclonedx" { return Err(DepsError(format!("unsupported SBOM format: {format}"))); } @@ -196,19 +214,165 @@ fn run_inner(sub: DepsSubcommand) -> Result { } } -fn should_fail(inv: &crate::deps::Inventory, threshold: &str) -> bool { - let Some(sev) = Severity::parse(threshold) else { - return false; - }; - inv.findings.iter().any(|f| f.severity.at_least(sev)) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputFormat { + Table, + Json, + Sarif, } -fn scan_base_ref(_path: &str, _base: &str) -> Result { - // Offline stub: diff against empty base when git checkout unavailable in tests - Ok(crate::deps::Inventory { - root: PathBuf::from("."), - detected_files: vec![], - graph: crate::deps::model::DependencyGraph::default(), - findings: vec![], +impl OutputFormat { + fn parse(value: Option<&str>) -> Result { + match value.unwrap_or("table") { + "table" => Ok(Self::Table), + "json" => Ok(Self::Json), + "sarif" => Ok(Self::Sarif), + other => Err(DepsError(format!( + "unsupported --out-format: {other}; expected table, json, or sarif" + ))), + } + } +} + +fn load_policy(root: &Path) -> Result { + let policy_path = root.join(".corgea").join("deps.yml"); + if !policy_path.exists() { + return Ok(Policy::default()); + } + let yaml = std::fs::read_to_string(&policy_path) + .map_err(|e| DepsError(format!("read policy {}: {e}", policy_path.display())))?; + Policy::from_yaml(&yaml) + .map_err(|e| DepsError(format!("load policy {}: {}", policy_path.display(), e.0))) +} + +fn parse_severity(value: &str, option: &str) -> Result { + Severity::parse(value).ok_or_else(|| { + DepsError(format!( + "unsupported severity for {option}: {value}; expected info, low, medium, high, or critical" + )) }) } + +fn should_fail(inv: &crate::deps::Inventory, threshold: Severity) -> bool { + inv.findings.iter().any(|f| f.severity.at_least(threshold)) +} + +fn has_new_findings_at_or_above( + base: &crate::deps::Inventory, + head: &crate::deps::Inventory, + threshold: Severity, +) -> bool { + let base_keys: HashSet = base.findings.iter().map(finding_key).collect(); + head.findings + .iter() + .any(|f| f.severity.at_least(threshold) && !base_keys.contains(&finding_key(f))) +} + +fn finding_key(finding: &Finding) -> String { + format!( + "{}\0{}\0{}\0{}\0{}", + finding.id, + finding.package.as_ref().map(|p| p.0.as_str()).unwrap_or(""), + finding.source_file, + finding.declared_constraint.as_deref().unwrap_or(""), + finding.resolved_version.as_deref().unwrap_or("") + ) +} + +fn scan_base_ref(path: &Path, base: &str) -> Result { + let head_path = std::fs::canonicalize(path) + .map_err(|e| DepsError(format!("resolve scan path {}: {e}", path.display())))?; + let repo_root_raw = git_output(&head_path, &["rev-parse", "--show-toplevel"])?; + let repo_root = std::fs::canonicalize(PathBuf::from(repo_root_raw.trim())) + .map_err(|e| DepsError(format!("resolve git root: {e}")))?; + let rel_path = head_path.strip_prefix(&repo_root).map_err(|_| { + DepsError(format!( + "scan path {} is outside git root {}", + head_path.display(), + repo_root.display() + )) + })?; + + let temp = tempfile::TempDir::new() + .map_err(|e| DepsError(format!("create temporary worktree directory: {e}")))?; + let worktree = temp.path().join("base"); + git_output_os( + &repo_root, + vec![ + OsString::from("worktree"), + OsString::from("add"), + OsString::from("--detach"), + OsString::from("--quiet"), + worktree.as_os_str().to_os_string(), + OsString::from(base), + ], + )?; + + let base_path = if rel_path.as_os_str().is_empty() { + worktree.clone() + } else { + worktree.join(rel_path) + }; + let result = if base_path.exists() { + load_policy(&base_path).and_then(|policy| scan(&base_path, &policy)) + } else { + Err(DepsError(format!( + "path {} does not exist at base ref {base}", + path.display() + ))) + }; + let cleanup = git_output_os( + &repo_root, + vec![ + OsString::from("worktree"), + OsString::from("remove"), + OsString::from("--force"), + worktree.as_os_str().to_os_string(), + ], + ); + + match (result, cleanup) { + (Ok(inv), Ok(_)) => Ok(inv), + (Err(e), _) => Err(e), + (Ok(_), Err(e)) => Err(DepsError(format!("cleanup base worktree: {e}"))), + } +} + +fn git_output(repo: &Path, args: &[&str]) -> Result { + git_output_os(repo, args.iter().map(OsString::from).collect()) +} + +fn git_output_os(repo: &Path, args: Vec) -> Result { + let mut command = Command::new("git"); + for var in GIT_LOCAL_ENV_VARS { + command.env_remove(var); + } + let output = command + .current_dir(repo) + .args(args) + .output() + .map_err(|e| DepsError(format!("run git: {e}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(DepsError(format!("git failed: {}", stderr.trim()))); + } + String::from_utf8(output.stdout).map_err(|e| DepsError(format!("read git output: {e}"))) +} + +const GIT_LOCAL_ENV_VARS: &[&str] = &[ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CONFIG", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_COUNT", + "GIT_OBJECT_DIRECTORY", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_IMPLICIT_WORK_TREE", + "GIT_GRAFT_FILE", + "GIT_INDEX_FILE", + "GIT_NO_REPLACE_OBJECTS", + "GIT_REPLACE_REF_BASE", + "GIT_PREFIX", + "GIT_SHALLOW_FILE", + "GIT_COMMON_DIR", +]; diff --git a/src/deps/tests/npm_tests.rs b/src/deps/tests/npm_tests.rs index c375cac..0b11e3c 100644 --- a/src/deps/tests/npm_tests.rs +++ b/src/deps/tests/npm_tests.rs @@ -45,6 +45,7 @@ fn npm_classify_git_commit_sha_is_immutable_ref() { } use super::common::scan_fixture; +use crate::deps::explain::explain; use crate::deps::model::{PackageId, Scope, Severity, SourceType}; #[test] @@ -194,3 +195,45 @@ fn node_app_lock_in_sync_no_dep002() { let inv = scan_fixture("node-app"); assert!(inv.with_code("DEP002").is_empty()); } + +#[test] +fn npm_graph_uses_generic_lockfile_edges() { + let inv = scan_fixture("node-transitive"); + let child = inv.node("child-lib").expect("child-lib node missing"); + assert!(!child.is_direct()); + let explanation = explain(&inv.graph, "child-lib").expect("child-lib explain"); + let paths: Vec> = explanation + .paths + .iter() + .map(|path| path.iter().map(|p| p.name().to_string()).collect()) + .collect(); + assert!( + paths + .iter() + .any(|path| path == &["root", "parent-lib", "child-lib"]), + "expected parent-lib -> child-lib path, got {paths:?}" + ); +} + +#[test] +fn npm_scoped_transitive_package_keeps_full_name() { + let inv = scan_fixture("node-transitive"); + let node = inv.node("@types/node").expect("@types/node node missing"); + assert_eq!(node.name(), "@types/node"); + assert_eq!(node.id().name(), "@types/node"); + assert_eq!(*node.id(), PackageId("pkg:npm/@types/node@18.19.0".into())); +} + +#[test] +fn yarn_lock_is_explicitly_unsupported_without_stale_noise() { + let inv = scan_fixture("node-yarn"); + assert!(!inv.with_code("DEP019").is_empty()); + assert!(inv.with_code("DEP002").is_empty()); +} + +#[test] +fn pnpm_lock_is_explicitly_unsupported_without_stale_noise() { + let inv = scan_fixture("node-pnpm"); + assert!(!inv.with_code("DEP019").is_empty()); + assert!(inv.with_code("DEP002").is_empty()); +} diff --git a/src/deps/tests/pypi_tests.rs b/src/deps/tests/pypi_tests.rs index e24aee6..56bc000 100644 --- a/src/deps/tests/pypi_tests.rs +++ b/src/deps/tests/pypi_tests.rs @@ -39,6 +39,7 @@ fn pypi_classify_git_branch_is_mutable_ref() { } use super::common::scan_fixture; +use crate::deps::explain::explain; use crate::deps::model::Scope; #[test] @@ -96,3 +97,32 @@ fn pypi_git_branch_dep_is_dep005() { .iter() .any(|f| f.id == "DEP005")); } + +#[test] +fn poetry_graph_uses_dependency_tables_for_multiple_parents() { + let inv = scan_fixture("python-poetry-multi"); + for (target, parent) in [("gamma", "alpha"), ("delta", "beta")] { + let node = inv.node(target).expect("transitive node missing"); + assert!(!node.is_direct()); + let explanation = explain(&inv.graph, target).expect("transitive explain"); + let paths: Vec> = explanation + .paths + .iter() + .map(|path| path.iter().map(|p| p.name().to_string()).collect()) + .collect(); + assert!( + paths.iter().any(|path| path == &["root", parent, target]), + "expected {parent} -> {target} path, got {paths:?}" + ); + } +} + +#[test] +fn uv_lock_does_not_suppress_requirements_scan() { + let inv = scan_fixture("python-uv-requirements"); + assert!(!inv.with_code("DEP001").is_empty()); + assert!(inv + .findings_for("requests") + .iter() + .any(|f| f.id == "DEP004")); +} diff --git a/tests/cli_deps.rs b/tests/cli_deps.rs index a24a092..f14a526 100644 --- a/tests/cli_deps.rs +++ b/tests/cli_deps.rs @@ -104,3 +104,378 @@ fn cli_scan_out_file_writes_json() { let written = std::fs::read_to_string(&out_file).expect("out-file should exist"); let _: serde_json::Value = serde_json::from_str(&written).expect("valid JSON"); } + +#[test] +fn cli_scan_rejects_invalid_out_format() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args(["deps", "scan", &fixture("node-app"), "--out-format", "typo"]) + .output() + .expect("failed to run corgea"); + assert_ne!(out.status.code(), Some(0)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("unsupported --out-format"), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn cli_scan_rejects_invalid_fail_on_severity() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args(["deps", "scan", &fixture("node-app"), "--fail-on", "hihg"]) + .output() + .expect("failed to run corgea"); + assert_ne!(out.status.code(), Some(0)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("unsupported severity for --fail-on"), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn cli_scan_loads_policy_created_by_policy_init() { + let project = TempDir::new().expect("temp project"); + write_exact_node_project(project.path(), "lodash", "*", "4.17.21"); + + let (mut default_cmd, _home) = corgea_isolated(); + let default_out = default_cmd + .args([ + "deps", + "scan", + project.path().to_str().unwrap(), + "--out-format", + "json", + ]) + .output() + .expect("failed to run corgea"); + assert!(default_out.status.success()); + let default_json: serde_json::Value = + serde_json::from_slice(&default_out.stdout).expect("valid JSON"); + assert!(finding_ids(&default_json).contains(&"DEP004".to_string())); + + let (mut init_cmd, _home) = corgea_isolated(); + let init_out = init_cmd + .args(["deps", "policy", "init", project.path().to_str().unwrap()]) + .output() + .expect("failed to run corgea"); + assert!( + init_out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&init_out.stderr) + ); + let policy_path = project.path().join(".corgea").join("deps.yml"); + let policy = std::fs::read_to_string(&policy_path) + .expect("policy init should create .corgea/deps.yml") + .replace("fail_on_wildcard: true", "fail_on_wildcard: false") + .replace("fail_on_latest: true", "fail_on_latest: false"); + std::fs::write(&policy_path, policy).expect("write edited policy"); + + let (mut scan_cmd, _home) = corgea_isolated(); + let out = scan_cmd + .args([ + "deps", + "scan", + project.path().to_str().unwrap(), + "--out-format", + "json", + ]) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let parsed: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid JSON"); + assert!(!finding_ids(&parsed).contains(&"DEP004".to_string())); +} + +#[test] +fn cli_scan_fails_closed_on_invalid_policy_yaml() { + let project = TempDir::new().expect("temp project"); + write_exact_node_project(project.path(), "lodash", "4.17.21", "4.17.21"); + let policy_dir = project.path().join(".corgea"); + std::fs::create_dir_all(&policy_dir).expect("create policy dir"); + std::fs::write(policy_dir.join("deps.yml"), "dependency_policy: [") + .expect("write invalid policy"); + + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args(["deps", "scan", project.path().to_str().unwrap()]) + .output() + .expect("failed to run corgea"); + assert_ne!(out.status.code(), Some(0)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("invalid policy YAML") + || String::from_utf8_lossy(&out.stderr).contains("invalid policy"), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn cli_diff_same_ref_has_empty_diff() { + let repo = TempDir::new().expect("temp repo"); + init_git_repo(repo.path()); + write_exact_node_project(repo.path(), "left-pad", "1.3.0", "1.3.0"); + commit_all(repo.path(), "base"); + + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .current_dir(repo.path()) + .args(["deps", "diff", "--base", "HEAD", "."]) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(!stdout.contains("\n + "), "stdout: {stdout}"); + assert!(!stdout.contains("\n - "), "stdout: {stdout}"); + assert!(!stdout.contains("\n ~ "), "stdout: {stdout}"); +} + +#[test] +fn cli_diff_reports_real_version_change_from_base_ref() { + let repo = TempDir::new().expect("temp repo"); + init_git_repo(repo.path()); + write_exact_node_project(repo.path(), "left-pad", "1.3.0", "1.3.0"); + commit_all(repo.path(), "base"); + write_exact_node_project(repo.path(), "left-pad", "1.3.1", "1.3.1"); + + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .current_dir(repo.path()) + .args(["deps", "diff", "--base", "HEAD", "."]) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("~ left-pad 1.3.0 -> 1.3.1"), + "stdout: {stdout}" + ); +} + +#[test] +fn cli_diff_fail_on_new_ignores_existing_high_findings() { + let repo = TempDir::new().expect("temp repo"); + init_git_repo(repo.path()); + write_exact_node_project(repo.path(), "lodash", "*", "4.17.21"); + commit_all(repo.path(), "base"); + + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .current_dir(repo.path()) + .args([ + "deps", + "diff", + "--base", + "HEAD", + ".", + "--fail-on-new", + "high", + ]) + .output() + .expect("failed to run corgea"); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn cli_diff_fail_on_new_applies_severity_to_new_findings() { + let repo = TempDir::new().expect("temp repo"); + init_git_repo(repo.path()); + write_exact_node_project(repo.path(), "left-pad", "1.3.0", "1.3.0"); + commit_all(repo.path(), "base"); + + write_exact_node_project(repo.path(), "left-pad", "^1.3.0", "1.3.0"); + let (mut medium_cmd, _home) = corgea_isolated(); + let medium_out = medium_cmd + .current_dir(repo.path()) + .args([ + "deps", + "diff", + "--base", + "HEAD", + ".", + "--fail-on-new", + "high", + ]) + .output() + .expect("failed to run corgea"); + assert_eq!( + medium_out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&medium_out.stderr) + ); + + write_exact_node_project(repo.path(), "left-pad", "*", "1.3.0"); + let (mut high_cmd, _home) = corgea_isolated(); + let high_out = high_cmd + .current_dir(repo.path()) + .args([ + "deps", + "diff", + "--base", + "HEAD", + ".", + "--fail-on-new", + "high", + ]) + .output() + .expect("failed to run corgea"); + assert_eq!( + high_out.status.code(), + Some(1), + "stderr: {}", + String::from_utf8_lossy(&high_out.stderr) + ); +} + +#[test] +fn cli_diff_rejects_invalid_fail_on_new_severity() { + let repo = TempDir::new().expect("temp repo"); + init_git_repo(repo.path()); + write_exact_node_project(repo.path(), "left-pad", "1.3.0", "1.3.0"); + commit_all(repo.path(), "base"); + + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .current_dir(repo.path()) + .args([ + "deps", + "diff", + "--base", + "HEAD", + ".", + "--fail-on-new", + "hihg", + ]) + .output() + .expect("failed to run corgea"); + assert_ne!(out.status.code(), Some(0)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("unsupported severity for --fail-on-new"), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn cli_diff_rejects_unknown_base_ref() { + let repo = TempDir::new().expect("temp repo"); + init_git_repo(repo.path()); + write_exact_node_project(repo.path(), "left-pad", "1.3.0", "1.3.0"); + commit_all(repo.path(), "base"); + + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .current_dir(repo.path()) + .args(["deps", "diff", "--base", "missing-ref", "."]) + .output() + .expect("failed to run corgea"); + assert_ne!(out.status.code(), Some(0)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("git failed"), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +fn finding_ids(json: &serde_json::Value) -> Vec { + json["findings"] + .as_array() + .expect("findings array") + .iter() + .filter_map(|finding| finding["id"].as_str().map(str::to_string)) + .collect() +} + +fn write_exact_node_project(root: &std::path::Path, name: &str, declared: &str, resolved: &str) { + let package_json = format!( + r#"{{ + "name": "diff-project", + "version": "1.0.0", + "dependencies": {{ + "{name}": "{declared}" + }} +}} +"# + ); + let package_lock = format!( + r#"{{ + "name": "diff-project", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": {{ + "": {{ + "name": "diff-project", + "version": "1.0.0", + "dependencies": {{ + "{name}": "{declared}" + }} + }}, + "node_modules/{name}": {{ + "version": "{resolved}", + "resolved": "https://registry.npmjs.org/{name}/-/{name}-{resolved}.tgz", + "integrity": "sha512-example" + }} + }} +}} +"# + ); + std::fs::write(root.join("package.json"), package_json).expect("write package.json"); + std::fs::write(root.join("package-lock.json"), package_lock).expect("write package-lock.json"); +} + +fn init_git_repo(repo: &std::path::Path) { + run_git(repo, &["init", "-q"]); +} + +fn commit_all(repo: &std::path::Path, message: &str) { + run_git(repo, &["add", "."]); + run_git( + repo, + &[ + "-c", + "user.email=test@example.com", + "-c", + "user.name=Test User", + "commit", + "-q", + "-m", + message, + ], + ); +} + +fn run_git(repo: &std::path::Path, args: &[&str]) { + let output = Command::new("git") + .current_dir(repo) + .args(args) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {:?} failed\nstdout: {}\nstderr: {}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index bad6d98..e09abb5 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -10,9 +10,13 @@ Offline fixture projects for `corgea deps` unit and CLI tests per `docs/PRD_DEPS |-----------|------| | `node-app` | npm graph + DEP003/004/005/008 | | `node-stale` | DEP002 stale lockfile | +| `node-transitive` | npm generic transitive edges + scoped package names | +| `node-yarn` / `node-pnpm` | unsupported npm-family lockfiles | | `node-monorepo` | workspace detection | | `python-poetry` | Poetry lock + transitive urllib3 | +| `python-poetry-multi` | Poetry dependency tables for multiple parents | | `python-pip-nolock` | DEP001 + requirements.txt | +| `python-uv-requirements` | `uv.lock` does not suppress requirements scanning | | `java-maven` / `java-gradle` | Maven/Gradle parsers | | `go-mod-smoke` | detection only | | `malformed/` | graceful parse errors | diff --git a/tests/fixtures/node-pnpm/package.json b/tests/fixtures/node-pnpm/package.json new file mode 100644 index 0000000..76f846f --- /dev/null +++ b/tests/fixtures/node-pnpm/package.json @@ -0,0 +1,7 @@ +{ + "name": "node-pnpm", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.3.0" + } +} diff --git a/tests/fixtures/node-pnpm/pnpm-lock.yaml b/tests/fixtures/node-pnpm/pnpm-lock.yaml new file mode 100644 index 0000000..a6cf0ea --- /dev/null +++ b/tests/fixtures/node-pnpm/pnpm-lock.yaml @@ -0,0 +1,5 @@ +lockfileVersion: '9.0' +packages: + left-pad@1.3.0: + resolution: + integrity: sha512-example diff --git a/tests/fixtures/node-transitive/package-lock.json b/tests/fixtures/node-transitive/package-lock.json new file mode 100644 index 0000000..c3c1544 --- /dev/null +++ b/tests/fixtures/node-transitive/package-lock.json @@ -0,0 +1,34 @@ +{ + "name": "node-transitive", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-transitive", + "version": "1.0.0", + "dependencies": { + "parent-lib": "1.0.0" + } + }, + "node_modules/parent-lib": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parent-lib/-/parent-lib-1.0.0.tgz", + "integrity": "sha512-parent", + "dependencies": { + "child-lib": "^2.0.0", + "@types/node": "^18.19.0" + } + }, + "node_modules/child-lib": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/child-lib/-/child-lib-2.1.0.tgz", + "integrity": "sha512-child" + }, + "node_modules/@types/node": { + "version": "18.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.0.tgz", + "integrity": "sha512-types" + } + } +} diff --git a/tests/fixtures/node-transitive/package.json b/tests/fixtures/node-transitive/package.json new file mode 100644 index 0000000..8d88ff9 --- /dev/null +++ b/tests/fixtures/node-transitive/package.json @@ -0,0 +1,7 @@ +{ + "name": "node-transitive", + "version": "1.0.0", + "dependencies": { + "parent-lib": "1.0.0" + } +} diff --git a/tests/fixtures/node-yarn/package.json b/tests/fixtures/node-yarn/package.json new file mode 100644 index 0000000..228f257 --- /dev/null +++ b/tests/fixtures/node-yarn/package.json @@ -0,0 +1,7 @@ +{ + "name": "node-yarn", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.3.0" + } +} diff --git a/tests/fixtures/node-yarn/yarn.lock b/tests/fixtures/node-yarn/yarn.lock new file mode 100644 index 0000000..bdb1f57 --- /dev/null +++ b/tests/fixtures/node-yarn/yarn.lock @@ -0,0 +1,4 @@ +left-pad@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz" + integrity sha512-example diff --git a/tests/fixtures/python-poetry-multi/poetry.lock b/tests/fixtures/python-poetry-multi/poetry.lock new file mode 100644 index 0000000..11a3c79 --- /dev/null +++ b/tests/fixtures/python-poetry-multi/poetry.lock @@ -0,0 +1,34 @@ +[[package]] +name = "alpha" +version = "1.0.1" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +gamma = ">=3,<4" + +[[package]] +name = "beta" +version = "2.0.1" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +delta = "^4.0" + +[[package]] +name = "gamma" +version = "3.2.1" +optional = false +python-versions = ">=3.8" + +[[package]] +name = "delta" +version = "4.1.0" +optional = false +python-versions = ">=3.8" + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "0000000000000000000000000000000000000000000000000000000000000000" diff --git a/tests/fixtures/python-poetry-multi/pyproject.toml b/tests/fixtures/python-poetry-multi/pyproject.toml new file mode 100644 index 0000000..f97bc94 --- /dev/null +++ b/tests/fixtures/python-poetry-multi/pyproject.toml @@ -0,0 +1,8 @@ +[tool.poetry] +name = "python-poetry-multi" +version = "0.1.0" + +[tool.poetry.dependencies] +python = "^3.12" +alpha = "^1.0.0" +beta = "^2.0.0" diff --git a/tests/fixtures/python-uv-requirements/pyproject.toml b/tests/fixtures/python-uv-requirements/pyproject.toml new file mode 100644 index 0000000..5510875 --- /dev/null +++ b/tests/fixtures/python-uv-requirements/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "python-uv-requirements" +version = "0.1.0" diff --git a/tests/fixtures/python-uv-requirements/requirements.txt b/tests/fixtures/python-uv-requirements/requirements.txt new file mode 100644 index 0000000..f229360 --- /dev/null +++ b/tests/fixtures/python-uv-requirements/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/tests/fixtures/python-uv-requirements/uv.lock b/tests/fixtures/python-uv-requirements/uv.lock new file mode 100644 index 0000000..d0e17e4 --- /dev/null +++ b/tests/fixtures/python-uv-requirements/uv.lock @@ -0,0 +1,2 @@ +version = 1 +revision = 1 From d0f66a4e2885182fda006054d7b387960e623b54 Mon Sep 17 00:00:00 2001 From: juangaitanv Date: Mon, 8 Jun 2026 11:00:30 +0200 Subject: [PATCH 6/9] Make post-edit hook Codex-safe --- .codex/hooks.json | 14 +++++++++++ harness | 59 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 .codex/hooks.json diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..9884114 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "./harness post-edit codex" + } + ] + } + ] + } +} diff --git a/harness b/harness index 61cc0b1..6192cc2 100755 --- a/harness +++ b/harness @@ -4,6 +4,7 @@ # # Commands: check, fix, lint, test, audit, coverage, pre-commit, ci, # post-edit, setup-hooks, suppressions +# Use `./harness post-edit codex` for Codex hooks that require JSON-safe stdout. set -u @@ -97,6 +98,20 @@ run_with_summary() { return 0 } +json_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + printf "%s" "$value" +} + +codex_system_message() { + printf '{"systemMessage":"%s"}\n' "$(json_escape "$1")" +} + # ── Git helpers ───────────────────────────────────────────────────── staged_rs_files() { @@ -192,8 +207,49 @@ cmd_coverage() { } cmd_post_edit() { + local codex=0 + local hook_input="" + local arg + for arg in "$@"; do + case "$arg" in + codex) codex=1 ;; + "") + ;; + *) + printf "Unknown post-edit option: %s\n" "$arg" >&2 + return 1 + ;; + esac + done + + if [ ! -t 0 ]; then + hook_input="$(cat || true)" + if printf "%s" "$hook_input" | grep -qE '"hook_event_name"[[:space:]]*:'; then + codex=1 + fi + fi + local changed; changed="$(changed_rs_files)" [ -z "$changed" ] && return 0 + + if [ "$codex" -eq 1 ]; then + local tmp; tmp="$(mktemp)" + cargo fmt >"$tmp" 2>&1 + local rc=$? + local output; output="$(tail -40 "$tmp")" + rm -f "$tmp" + + [ "$rc" -eq 0 ] && return 0 + + if [ -n "$output" ]; then + codex_system_message "cargo fmt failed: +$output" + else + codex_system_message "cargo fmt failed without output." + fi + return 0 + fi + # Never fail the Stop hook. run "Format" 1 -- cargo fmt || true return 0 @@ -278,13 +334,14 @@ case "$cmd" in coverage) cmd_coverage ;; pre-commit) cmd_pre_commit ;; ci) cmd_ci ;; - post-edit) cmd_post_edit ;; + post-edit) cmd_post_edit "${@:2}" ;; setup-hooks) cmd_setup_hooks ;; suppressions) cmd_suppressions ;; -h|--help|help) printf "Usage: ./harness [--verbose] [--min=N]\n\n" printf "Commands: check, fix, lint, test, audit, coverage, pre-commit,\n" printf " ci, post-edit, setup-hooks, suppressions\n" + printf "\nCodex hook mode: ./harness post-edit codex\n" ;; *) printf "Unknown command: %s\n" "$cmd" >&2 From d3388597a2e65c67a064acf03ed20e1fa45581cb Mon Sep 17 00:00:00 2001 From: juangaitanv Date: Mon, 8 Jun 2026 11:25:12 +0200 Subject: [PATCH 7/9] Add agent-aware deps output modes --- README.md | 12 +- skills/corgea/SKILL.md | 20 ++ src/deps/report.rs | 11 +- src/deps/run.rs | 442 ++++++++++++++++++++++++++++++++++++----- tests/cli_deps.rs | 207 ++++++++++++++++++- 5 files changed, 632 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index b9ea1a2..03b116d 100644 --- a/README.md +++ b/README.md @@ -34,14 +34,20 @@ no token or network required. ```bash corgea deps scan # table report for the current directory +corgea deps scan --format agent # compact TSV for coding agents +corgea deps scan --format json # JSON inventory on stdout +corgea deps scan --format quiet # no stdout; exit code still applies corgea deps scan --fail-on high # exit 1 if any finding is >= high corgea deps scan --out-format json # machine-readable (json or sarif) -corgea deps graph # print the resolved dependency graph -corgea deps explain # show why a package is present +corgea deps graph --format json # print the resolved dependency graph +corgea deps explain --format agent # show why a package is present +corgea deps diff --base origin/main --format json corgea deps sbom --format cyclonedx # emit a CycloneDX SBOM -corgea deps policy init # write a starter .corgea/deps.yml +corgea deps policy init --exist-ok # write starter policy, or keep existing file ``` +`corgea deps` defaults to `--format agent` when an agent environment is detected (`AI_AGENT`, `CODEX_SANDBOX`, `CLAUDECODE`, and related agent variables). Use `--format human` to force the normal terminal output. + See [Dependency Scanning (CLI)](https://docs.corgea.app/cli/deps) for the full flag and exit-code reference. ## Development Setup diff --git a/skills/corgea/SKILL.md b/skills/corgea/SKILL.md index 2429d9c..f311aca 100644 --- a/skills/corgea/SKILL.md +++ b/skills/corgea/SKILL.md @@ -109,6 +109,26 @@ corgea setup-hooks --default-config # Default: secrets + PII, fail on Installs a pre-commit hook running `corgea scan blast --only-uncommitted`. Bypass with `git commit --no-verify`. +### Deps — `corgea deps ` + +Offline dependency inventory and policy checks. No Corgea token or network required. + +```bash +corgea deps scan # Human table; auto-agent TSV under AI_AGENT/CODEX_SANDBOX/etc. +corgea deps scan --format agent # Compact TSV summary + findings +corgea deps scan --format json # JSON inventory on stdout +corgea deps scan --format quiet --fail-on high # No stdout; exit code still applies +corgea deps scan --out-format sarif --out-file deps.sarif # Export table/json/sarif report +corgea deps graph --format agent # TSV graph: id, name, version, direct, scope, depth +corgea deps graph --format json # JSON graph nodes +corgea deps explain lodash --format agent # TSV dependency paths for a package +corgea deps diff --base origin/main --format json # JSON added/removed/changed dependencies +corgea deps sbom --format cyclonedx # CycloneDX SBOM JSON +corgea deps policy init --exist-ok --format quiet # Keep existing policy, no stdout +``` + +Render modes for deps commands are `--format human|agent|json|quiet`. `deps scan --out-format table|json|sarif` remains the report/export selector; do not combine it with `deps scan --format`. + ## Common Workflows ### Scan full project diff --git a/src/deps/report.rs b/src/deps/report.rs index c6bda35..810f466 100644 --- a/src/deps/report.rs +++ b/src/deps/report.rs @@ -91,9 +91,8 @@ pub fn to_cyclonedx(graph: &DependencyGraph) -> Value { }) } -pub fn inventory_to_json(inv: &Inventory) -> Value { - let nodes: Vec = inv - .graph +pub fn graph_nodes_json(graph: &DependencyGraph) -> Vec { + graph .nodes .iter() .map(|n| { @@ -106,7 +105,11 @@ pub fn inventory_to_json(inv: &Inventory) -> Value { "depth": n.depth(), }) }) - .collect(); + .collect() +} + +pub fn inventory_to_json(inv: &Inventory) -> Value { + let nodes = graph_nodes_json(&inv.graph); let findings: Vec = inv .findings diff --git a/src/deps/run.rs b/src/deps/run.rs index 6042f7d..6383d07 100644 --- a/src/deps/run.rs +++ b/src/deps/run.rs @@ -3,12 +3,13 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Command; -use clap::Subcommand; +use clap::{Args, Subcommand}; +use serde_json::{json, Value}; use crate::deps::findings::Finding; -use crate::deps::model::Severity; +use crate::deps::model::{DependencyNode, Severity}; use crate::deps::policy::Policy; -use crate::deps::report::{print_table, table_output, to_cyclonedx, to_json, to_sarif}; +use crate::deps::report::{graph_nodes_json, table_output, to_cyclonedx, to_json, to_sarif}; use crate::deps::{scan, DepsError}; #[derive(Subcommand, Debug, Clone)] @@ -23,17 +24,23 @@ pub enum DepsSubcommand { out_format: Option, #[arg(long, help = "Write output to this file")] out_file: Option, + #[command(flatten)] + render: RenderArgs, }, /// Print the dependency graph Graph { #[arg(default_value = ".")] path: String, + #[command(flatten)] + render: RenderArgs, }, /// Explain why a package is present Explain { package: String, #[arg(default_value = ".")] path: String, + #[command(flatten)] + render: RenderArgs, }, /// Compare dependency graph against a git ref Diff { @@ -43,6 +50,8 @@ pub enum DepsSubcommand { path: String, #[arg(long)] fail_on_new: Option, + #[command(flatten)] + render: RenderArgs, }, /// Generate an SBOM Sbom { @@ -66,9 +75,36 @@ pub enum DepsPolicySubcommand { Init { #[arg(default_value = ".")] path: String, + #[arg( + long, + help = "Succeed without rewriting when .corgea/deps.yml already exists" + )] + exist_ok: bool, + #[command(flatten)] + render: RenderArgs, }, } +#[derive(Args, Debug, Clone, Default)] +pub struct RenderArgs { + #[arg( + long, + value_name = "human|agent|json|quiet", + help = "Render output for humans, agents, JSON parsers, or suppress stdout" + )] + format: Option, +} + +impl RenderArgs { + fn is_set(&self) -> bool { + self.format.is_some() + } + + fn resolve(&self) -> Result { + RenderFormat::resolve(self.format.as_deref()) + } +} + pub fn run(sub: DepsSubcommand) -> u8 { match run_inner(sub) { Ok(code) => code, @@ -86,8 +122,14 @@ fn run_inner(sub: DepsSubcommand) -> Result { fail_on, out_format, out_file, + render, } => { - let format = OutputFormat::parse(out_format.as_deref())?; + if out_format.is_some() && render.is_set() { + return Err(DepsError( + "--format cannot be used with --out-format; choose one output selector" + .to_string(), + )); + } let fail_threshold = fail_on .as_deref() .map(|threshold| parse_severity(threshold, "--fail-on")) @@ -95,20 +137,17 @@ fn run_inner(sub: DepsSubcommand) -> Result { let root = Path::new(&path); let policy = load_policy(root)?; let inv = scan(root, &policy)?; - let output = match format { - OutputFormat::Table => table_output(&inv), - OutputFormat::Json => to_json(&inv).to_string(), - OutputFormat::Sarif => to_sarif(&inv).to_string(), + let output = if let Some(out_format) = out_format.as_deref() { + match ReportFormat::parse(out_format)? { + ReportFormat::Table => table_output(&inv), + ReportFormat::Json => json_line(to_json(&inv)), + ReportFormat::Sarif => json_line(to_sarif(&inv)), + } + } else { + render_scan(&inv, render.resolve()?) }; - if let Some(ref file) = out_file { - std::fs::write(file, &output) - .map_err(|e| DepsError(format!("write out-file: {e}")))?; - } else if format == OutputFormat::Table { - print_table(&inv); - } else { - println!("{output}"); - } + emit_output(&output, out_file.as_deref())?; if let Some(threshold) = fail_threshold { if should_fail(&inv, threshold) { @@ -117,33 +156,26 @@ fn run_inner(sub: DepsSubcommand) -> Result { } Ok(0) } - DepsSubcommand::Graph { path } => { + DepsSubcommand::Graph { path, render } => { let root = Path::new(&path); let policy = load_policy(root)?; let inv = scan(root, &policy)?; - for n in &inv.graph.nodes { - println!( - "{} {} direct={} scope={:?} depth={}", - n.name(), - n.version().unwrap_or("?"), - n.is_direct(), - n.scope(), - n.depth() - ); - } + let output = render_graph(&inv, render.resolve()?); + emit_output(&output, None)?; Ok(0) } - DepsSubcommand::Explain { package, path } => { + DepsSubcommand::Explain { + package, + path, + render, + } => { let root = Path::new(&path); let policy = load_policy(root)?; let inv = scan(root, &policy)?; match crate::deps::explain::explain(&inv.graph, &package) { Some(e) => { - println!("{} direct={} depth={}", package, e.direct, e.depth); - for path in &e.paths { - let line: Vec<_> = path.iter().map(|p| p.name()).collect(); - println!(" path: {}", line.join(" -> ")); - } + let output = render_explanation(&package, &e, render.resolve()?); + emit_output(&output, None)?; } None => { return Err(DepsError(format!("package not found: {package}"))); @@ -155,6 +187,7 @@ fn run_inner(sub: DepsSubcommand) -> Result { base, path, fail_on_new, + render, } => { let new_threshold = fail_on_new .as_deref() @@ -165,22 +198,13 @@ fn run_inner(sub: DepsSubcommand) -> Result { let head = scan(root, &policy)?; let base_inv = scan_base_ref(root, &base)?; let diff = crate::deps::diff::diff_graphs(&base_inv.graph, &head.graph); - println!("Dependency diff against {base}"); - for n in &diff.added { - println!(" + {}@{}", n.name(), n.version().unwrap_or("?")); - } - for n in &diff.removed { - println!(" - {}@{}", n.name(), n.version().unwrap_or("?")); - } - for c in &diff.changed { - println!(" ~ {} {} -> {}", c.name, c.from, c.to); - } + let output = render_diff(&base, &diff, render.resolve()?); + emit_output(&output, None)?; if let Some(threshold) = new_threshold { if has_new_findings_at_or_above(&base_inv, &head, threshold) { return Ok(1); } } - let _ = diff; Ok(0) } DepsSubcommand::Sbom { format, path, out } => { @@ -200,14 +224,24 @@ fn run_inner(sub: DepsSubcommand) -> Result { Ok(0) } DepsSubcommand::Policy { command } => match command { - DepsPolicySubcommand::Init { path } => { + DepsPolicySubcommand::Init { + path, + exist_ok, + render, + } => { let dir = PathBuf::from(path).join(".corgea"); std::fs::create_dir_all(&dir) .map_err(|e| DepsError(format!("create .corgea: {e}")))?; let policy_path = dir.join("deps.yml"); - std::fs::write(&policy_path, Policy::default_yaml()) - .map_err(|e| DepsError(format!("write policy: {e}")))?; - println!("Wrote {}", policy_path.display()); + let created = if policy_path.exists() && exist_ok { + false + } else { + std::fs::write(&policy_path, Policy::default_yaml()) + .map_err(|e| DepsError(format!("write policy: {e}")))?; + true + }; + let output = render_policy_init(&policy_path, created, render.resolve()?); + emit_output(&output, None)?; Ok(0) } }, @@ -215,15 +249,15 @@ fn run_inner(sub: DepsSubcommand) -> Result { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum OutputFormat { +enum ReportFormat { Table, Json, Sarif, } -impl OutputFormat { - fn parse(value: Option<&str>) -> Result { - match value.unwrap_or("table") { +impl ReportFormat { + fn parse(value: &str) -> Result { + match value { "table" => Ok(Self::Table), "json" => Ok(Self::Json), "sarif" => Ok(Self::Sarif), @@ -234,6 +268,310 @@ impl OutputFormat { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RenderFormat { + Human, + Agent, + Json, + Quiet, +} + +impl RenderFormat { + fn resolve(value: Option<&str>) -> Result { + match value { + Some("human") => Ok(Self::Human), + Some("agent") => Ok(Self::Agent), + Some("json") => Ok(Self::Json), + Some("quiet") => Ok(Self::Quiet), + Some(other) => Err(DepsError(format!( + "unsupported --format: {other}; expected human, agent, json, or quiet" + ))), + None if agent_env_detected() => Ok(Self::Agent), + None => Ok(Self::Human), + } + } +} + +fn agent_env_detected() -> bool { + [ + "AI_AGENT", + "CODEX_SANDBOX", + "CLAUDECODE", + "CLAUDE_CODE", + "CURSOR_AGENT", + "CURSOR_TRACE_ID", + "GEMINI_CLI", + "PI_AGENT", + ] + .iter() + .any(|name| match std::env::var(name) { + Ok(value) => { + let normalized = value.trim().to_ascii_lowercase(); + !normalized.is_empty() && normalized != "0" && normalized != "false" + } + Err(_) => false, + }) +} + +fn emit_output(output: &str, out_file: Option<&str>) -> Result<(), DepsError> { + if let Some(file) = out_file { + std::fs::write(file, output).map_err(|e| DepsError(format!("write out-file: {e}")))?; + } else if !output.is_empty() { + print!("{output}"); + } + Ok(()) +} + +fn json_line(value: Value) -> String { + format!("{value}\n") +} + +fn render_scan(inv: &crate::deps::Inventory, format: RenderFormat) -> String { + match format { + RenderFormat::Human => table_output(inv), + RenderFormat::Agent => agent_scan_output(inv), + RenderFormat::Json => json_line(to_json(inv)), + RenderFormat::Quiet => String::new(), + } +} + +fn agent_scan_output(inv: &crate::deps::Inventory) -> String { + let mut out = String::new(); + out.push_str("record\troot\tdetected_files\tpackages\tfindings\n"); + out.push_str(&format!( + "summary\t{}\t{}\t{}\t{}\n", + tsv_cell(&inv.root.display().to_string()), + inv.detected_files.len(), + inv.graph.nodes.len(), + inv.findings.len() + )); + out.push_str("record\tid\tseverity\tpackage\ttitle\trecommendation\n"); + for finding in &inv.findings { + let package = finding + .package + .as_ref() + .map(|package| package.0.as_str()) + .unwrap_or("project"); + out.push_str(&format!( + "finding\t{}\t{:?}\t{}\t{}\t{}\n", + tsv_cell(&finding.id), + finding.severity, + tsv_cell(package), + tsv_cell(&finding.title), + tsv_cell(&finding.recommendation) + )); + } + out +} + +fn render_graph(inv: &crate::deps::Inventory, format: RenderFormat) -> String { + match format { + RenderFormat::Human => human_graph_output(inv), + RenderFormat::Agent => agent_graph_output(inv), + RenderFormat::Json => json_line(json!({ "nodes": graph_nodes_json(&inv.graph) })), + RenderFormat::Quiet => String::new(), + } +} + +fn human_graph_output(inv: &crate::deps::Inventory) -> String { + let mut out = String::new(); + for node in &inv.graph.nodes { + out.push_str(&format!( + "{} {} direct={} scope={:?} depth={}\n", + node.name(), + node.version().unwrap_or("?"), + node.is_direct(), + node.scope(), + node.depth() + )); + } + out +} + +fn agent_graph_output(inv: &crate::deps::Inventory) -> String { + let mut out = String::from("id\tname\tversion\tdirect\tscope\tdepth\n"); + for node in &inv.graph.nodes { + out.push_str(&format!( + "{}\t{}\t{}\t{}\t{:?}\t{}\n", + tsv_cell(&node.id().0), + tsv_cell(node.name()), + tsv_cell(node.version().unwrap_or("")), + node.is_direct(), + node.scope(), + node.depth() + )); + } + out +} + +fn render_explanation( + package: &str, + explanation: &crate::deps::explain::Explanation, + format: RenderFormat, +) -> String { + match format { + RenderFormat::Human => human_explanation_output(package, explanation), + RenderFormat::Agent => agent_explanation_output(package, explanation), + RenderFormat::Json => json_line(explanation_json(package, explanation)), + RenderFormat::Quiet => String::new(), + } +} + +fn human_explanation_output( + package: &str, + explanation: &crate::deps::explain::Explanation, +) -> String { + let mut out = format!( + "{} direct={} depth={}\n", + package, explanation.direct, explanation.depth + ); + for path in &explanation.paths { + let line: Vec<_> = path.iter().map(|package| package.name()).collect(); + out.push_str(&format!(" path: {}\n", line.join(" -> "))); + } + out +} + +fn agent_explanation_output( + package: &str, + explanation: &crate::deps::explain::Explanation, +) -> String { + let mut out = String::from("record\tpackage\tdirect\tdepth\tpath\n"); + for path in &explanation.paths { + let line: Vec<_> = path.iter().map(|package| package.name()).collect(); + out.push_str(&format!( + "path\t{}\t{}\t{}\t{}\n", + tsv_cell(package), + explanation.direct, + explanation.depth, + tsv_cell(&line.join(" -> ")) + )); + } + out +} + +fn explanation_json(package: &str, explanation: &crate::deps::explain::Explanation) -> Value { + let paths: Vec> = explanation + .paths + .iter() + .map(|path| path.iter().map(|package| package.name()).collect()) + .collect(); + json!({ + "package": package, + "direct": explanation.direct, + "depth": explanation.depth, + "paths": paths, + }) +} + +fn render_diff(base: &str, diff: &crate::deps::diff::GraphDiff, format: RenderFormat) -> String { + match format { + RenderFormat::Human => human_diff_output(base, diff), + RenderFormat::Agent => agent_diff_output(diff), + RenderFormat::Json => json_line(diff_json(base, diff)), + RenderFormat::Quiet => String::new(), + } +} + +fn human_diff_output(base: &str, diff: &crate::deps::diff::GraphDiff) -> String { + let mut out = format!("Dependency diff against {base}\n"); + for node in &diff.added { + out.push_str(&format!( + " + {}@{}\n", + node.name(), + node.version().unwrap_or("?") + )); + } + for node in &diff.removed { + out.push_str(&format!( + " - {}@{}\n", + node.name(), + node.version().unwrap_or("?") + )); + } + for change in &diff.changed { + out.push_str(&format!( + " ~ {} {} -> {}\n", + change.name, change.from, change.to + )); + } + out +} + +fn agent_diff_output(diff: &crate::deps::diff::GraphDiff) -> String { + let mut out = String::from("change\tname\tfrom\tto\n"); + for node in &diff.added { + out.push_str(&format!( + "added\t{}\t\t{}\n", + tsv_cell(node.name()), + tsv_cell(node.version().unwrap_or("")) + )); + } + for node in &diff.removed { + out.push_str(&format!( + "removed\t{}\t{}\t\n", + tsv_cell(node.name()), + tsv_cell(node.version().unwrap_or("")) + )); + } + for change in &diff.changed { + out.push_str(&format!( + "changed\t{}\t{}\t{}\n", + tsv_cell(&change.name), + tsv_cell(&change.from), + tsv_cell(&change.to) + )); + } + out +} + +fn diff_json(base: &str, diff: &crate::deps::diff::GraphDiff) -> Value { + let changed: Vec = diff + .changed + .iter() + .map(|change| { + json!({ + "name": change.name, + "from": change.from, + "to": change.to, + }) + }) + .collect(); + json!({ + "base": base, + "added": diff_nodes_json(&diff.added), + "removed": diff_nodes_json(&diff.removed), + "changed": changed, + }) +} + +fn diff_nodes_json(nodes: &[DependencyNode]) -> Vec { + nodes + .iter() + .map(|node| { + json!({ + "name": node.name(), + "version": node.version(), + }) + }) + .collect() +} + +fn render_policy_init(path: &Path, created: bool, format: RenderFormat) -> String { + let path = path.display().to_string(); + match format { + RenderFormat::Human if created => format!("Wrote {path}\n"), + RenderFormat::Human => format!("Exists {path}\n"), + RenderFormat::Agent => format!("path\n{}\n", tsv_cell(&path)), + RenderFormat::Json => json_line(json!({ "path": path, "created": created })), + RenderFormat::Quiet => String::new(), + } +} + +fn tsv_cell(value: &str) -> String { + value.replace(['\t', '\r', '\n'], " ") +} + fn load_policy(root: &Path) -> Result { let policy_path = root.join(".corgea").join("deps.yml"); if !policy_path.exists() { diff --git a/tests/cli_deps.rs b/tests/cli_deps.rs index f14a526..49ba9c4 100644 --- a/tests/cli_deps.rs +++ b/tests/cli_deps.rs @@ -7,7 +7,15 @@ fn corgea_isolated() -> (Command, TempDir) { cmd.env("HOME", home.path()) .env("USERPROFILE", home.path()) .env_remove("CORGEA_TOKEN") - .env_remove("CORGEA_URL"); + .env_remove("CORGEA_URL") + .env_remove("AI_AGENT") + .env_remove("CODEX_SANDBOX") + .env_remove("CLAUDECODE") + .env_remove("CLAUDE_CODE") + .env_remove("CURSOR_AGENT") + .env_remove("CURSOR_TRACE_ID") + .env_remove("GEMINI_CLI") + .env_remove("PI_AGENT"); (cmd, home) } @@ -73,6 +81,119 @@ fn cli_scan_clean_fixture_fail_on_high_exits_zero() { assert_eq!(out.status.code(), Some(0)); } +#[test] +fn cli_scan_agent_env_defaults_to_agent_format() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .env("AI_AGENT", "1") + .args(["deps", "scan", &fixture("node-app")]) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.starts_with("record\troot\t"), "stdout: {stdout}"); + assert!( + stdout.contains("\nfinding\tDEP004\tHigh\tpkg:npm/lodash@4.17.21\t"), + "stdout: {stdout}" + ); +} + +#[test] +fn cli_scan_format_human_overrides_agent_env() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .env("AI_AGENT", "1") + .args(["deps", "scan", &fixture("node-app"), "--format", "human"]) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("Corgea dependency inventory"), + "stdout: {stdout}" + ); +} + +#[test] +fn cli_scan_format_json_outputs_parseable_inventory() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args(["deps", "scan", &fixture("node-app"), "--format", "json"]) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let parsed: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON"); + assert!(parsed.get("nodes").is_some()); + assert!(parsed.get("findings").is_some()); +} + +#[test] +fn cli_scan_format_quiet_suppresses_stdout_and_preserves_fail_code() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args([ + "deps", + "scan", + &fixture("node-app"), + "--format", + "quiet", + "--fail-on", + "high", + ]) + .output() + .expect("failed to run corgea"); + assert_eq!(out.status.code(), Some(1)); + assert_eq!(out.stdout, b""); +} + +#[test] +fn cli_graph_format_json_outputs_parseable_nodes() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args(["deps", "graph", &fixture("node-app"), "--format", "json"]) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let parsed: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON"); + let nodes = parsed["nodes"].as_array().expect("nodes array"); + assert!(nodes + .iter() + .any(|node| node["id"] == "pkg:npm/left-pad@1.3.0")); +} + +#[test] +fn cli_deps_rejects_invalid_render_format() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args(["deps", "graph", &fixture("node-app"), "--format", "typo"]) + .output() + .expect("failed to run corgea"); + assert_ne!(out.status.code(), Some(0)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("unsupported --format"), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + #[test] fn cli_deps_without_subcommand_exits_nonzero() { let (mut cmd, _home) = corgea_isolated(); @@ -120,6 +241,29 @@ fn cli_scan_rejects_invalid_out_format() { ); } +#[test] +fn cli_scan_rejects_render_format_with_out_format() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args([ + "deps", + "scan", + &fixture("node-app"), + "--format", + "agent", + "--out-format", + "json", + ]) + .output() + .expect("failed to run corgea"); + assert_ne!(out.status.code(), Some(0)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("--format cannot be used with --out-format"), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + #[test] fn cli_scan_rejects_invalid_fail_on_severity() { let (mut cmd, _home) = corgea_isolated(); @@ -193,6 +337,41 @@ fn cli_scan_loads_policy_created_by_policy_init() { assert!(!finding_ids(&parsed).contains(&"DEP004".to_string())); } +#[test] +fn cli_policy_init_exist_ok_preserves_existing_policy() { + let project = TempDir::new().expect("temp project"); + let policy_dir = project.path().join(".corgea"); + std::fs::create_dir_all(&policy_dir).expect("create policy dir"); + let policy_path = policy_dir.join("deps.yml"); + std::fs::write(&policy_path, "custom: true\n").expect("write existing policy"); + + let (mut init_cmd, _home) = corgea_isolated(); + let init_out = init_cmd + .args([ + "deps", + "policy", + "init", + project.path().to_str().unwrap(), + "--exist-ok", + "--format", + "json", + ]) + .output() + .expect("failed to run corgea"); + assert!( + init_out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&init_out.stderr) + ); + let parsed: serde_json::Value = + serde_json::from_slice(&init_out.stdout).expect("stdout must be valid JSON"); + assert_eq!(parsed["created"], false); + assert_eq!( + std::fs::read_to_string(&policy_path).expect("read existing policy"), + "custom: true\n" + ); +} + #[test] fn cli_scan_fails_closed_on_invalid_policy_yaml() { let project = TempDir::new().expect("temp project"); @@ -266,6 +445,32 @@ fn cli_diff_reports_real_version_change_from_base_ref() { ); } +#[test] +fn cli_diff_format_json_reports_real_version_change_from_base_ref() { + let repo = TempDir::new().expect("temp repo"); + init_git_repo(repo.path()); + write_exact_node_project(repo.path(), "left-pad", "1.3.0", "1.3.0"); + commit_all(repo.path(), "base"); + write_exact_node_project(repo.path(), "left-pad", "1.3.1", "1.3.1"); + + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .current_dir(repo.path()) + .args(["deps", "diff", "--base", "HEAD", ".", "--format", "json"]) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let parsed: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON"); + assert_eq!(parsed["changed"][0]["name"], "left-pad"); + assert_eq!(parsed["changed"][0]["from"], "1.3.0"); + assert_eq!(parsed["changed"][0]["to"], "1.3.1"); +} + #[test] fn cli_diff_fail_on_new_ignores_existing_high_findings() { let repo = TempDir::new().expect("temp repo"); From 4a8626e22b459e353d4867e5d9746516c9465f79 Mon Sep 17 00:00:00 2001 From: juangaitanv Date: Mon, 8 Jun 2026 12:55:24 +0200 Subject: [PATCH 8/9] Improve deps agent discoverability --- examples/deps_skill.rs | 31 +++++++ harness | 11 ++- skills/corgea/SKILL.md | 34 ++++---- src/deps/mod.rs | 1 + src/deps/run.rs | 94 +++++++++++++++++++- src/deps/skill.rs | 185 ++++++++++++++++++++++++++++++++++++++++ tests/cli_deps.rs | 127 +++++++++++++++++++++++++++ tests/cli_deps_skill.rs | 10 +++ 8 files changed, 472 insertions(+), 21 deletions(-) create mode 100644 examples/deps_skill.rs create mode 100644 src/deps/skill.rs create mode 100644 tests/cli_deps_skill.rs diff --git a/examples/deps_skill.rs b/examples/deps_skill.rs new file mode 100644 index 0000000..f4a5b91 --- /dev/null +++ b/examples/deps_skill.rs @@ -0,0 +1,31 @@ +use std::path::Path; +use std::process::ExitCode; + +use corgea::deps::skill::{check_skill_file, generated_marked_section, update_skill_file}; + +const SKILL_PATH: &str = "skills/corgea/SKILL.md"; + +fn main() -> ExitCode { + let mode = std::env::args() + .nth(1) + .unwrap_or_else(|| "print".to_string()); + let skill_path = Path::new(SKILL_PATH); + + let result = match mode.as_str() { + "print" => { + println!("{}", generated_marked_section()); + Ok(()) + } + "check" => check_skill_file(skill_path), + "update" => update_skill_file(skill_path), + _ => Err("usage: cargo run --example deps_skill -- [print|check|update]".to_string()), + }; + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + } +} diff --git a/harness b/harness index 6192cc2..84b5076 100755 --- a/harness +++ b/harness @@ -3,7 +3,7 @@ # Usage: ./harness [--verbose] [--min=N] # # Commands: check, fix, lint, test, audit, coverage, pre-commit, ci, -# post-edit, setup-hooks, suppressions +# post-edit, setup-hooks, suppressions, deps-skill # Use `./harness post-edit codex` for Codex hooks that require JSON-safe stdout. set -u @@ -175,6 +175,10 @@ cmd_test() { run_with_summary "Tests" 0 -- cargo test } +cmd_deps_skill() { + run "Deps skill drift" 0 -- cargo run --example deps_skill -- check +} + cmd_audit() { _cmd_audit_inner 0 } @@ -282,6 +286,8 @@ cmd_check() { [ $? -eq 0 ] && passed=$(( passed + 1 )) || failed=$(( failed + 1 )) run_with_summary "Tests" 1 -- cargo test [ $? -eq 0 ] && passed=$(( passed + 1 )) || failed=$(( failed + 1 )) + run "Deps skill drift" 1 -- cargo run --example deps_skill -- check + [ $? -eq 0 ] && passed=$(( passed + 1 )) || failed=$(( failed + 1 )) cmd_suppressions @@ -337,10 +343,11 @@ case "$cmd" in post-edit) cmd_post_edit "${@:2}" ;; setup-hooks) cmd_setup_hooks ;; suppressions) cmd_suppressions ;; + deps-skill) cmd_deps_skill ;; -h|--help|help) printf "Usage: ./harness [--verbose] [--min=N]\n\n" printf "Commands: check, fix, lint, test, audit, coverage, pre-commit,\n" - printf " ci, post-edit, setup-hooks, suppressions\n" + printf " ci, post-edit, setup-hooks, suppressions, deps-skill\n" printf "\nCodex hook mode: ./harness post-edit codex\n" ;; *) diff --git a/skills/corgea/SKILL.md b/skills/corgea/SKILL.md index f311aca..f23293f 100644 --- a/skills/corgea/SKILL.md +++ b/skills/corgea/SKILL.md @@ -109,25 +109,27 @@ corgea setup-hooks --default-config # Default: secrets + PII, fail on Installs a pre-commit hook running `corgea scan blast --only-uncommitted`. Bypass with `git commit --no-verify`. + ### Deps — `corgea deps ` Offline dependency inventory and policy checks. No Corgea token or network required. - -```bash -corgea deps scan # Human table; auto-agent TSV under AI_AGENT/CODEX_SANDBOX/etc. -corgea deps scan --format agent # Compact TSV summary + findings -corgea deps scan --format json # JSON inventory on stdout -corgea deps scan --format quiet --fail-on high # No stdout; exit code still applies -corgea deps scan --out-format sarif --out-file deps.sarif # Export table/json/sarif report -corgea deps graph --format agent # TSV graph: id, name, version, direct, scope, depth -corgea deps graph --format json # JSON graph nodes -corgea deps explain lodash --format agent # TSV dependency paths for a package -corgea deps diff --base origin/main --format json # JSON added/removed/changed dependencies -corgea deps sbom --format cyclonedx # CycloneDX SBOM JSON -corgea deps policy init --exist-ok --format quiet # Keep existing policy, no stdout -``` - -Render modes for deps commands are `--format human|agent|json|quiet`. `deps scan --out-format table|json|sarif` remains the report/export selector; do not combine it with `deps scan --format`. +Agent environments default to compact TSV; force output with `--format human|agent|json|quiet`. + +- `corgea deps scan [PATH]` — Scan manifests and lockfiles, build inventory, evaluate policy. Flags: `--fail-on`, `--out-format`, `--out-file`, `--format` + Examples: `corgea deps scan --format agent`; `corgea deps scan --format quiet --fail-on high` +- `corgea deps graph [PATH]` — Print the dependency graph. Flags: `--format` + Examples: `corgea deps graph --format agent`; `corgea deps graph tests/fixtures/node-app --format json` +- `corgea deps explain [PATH]` — Explain why a package is present. Flags: `--format` + Examples: `corgea deps explain lodash --format agent`; `corgea deps explain left-pad tests/fixtures/node-app --format json` +- `corgea deps diff --base [PATH]` — Compare dependency graph against a git ref. Flags: `--base`, `--fail-on-new`, `--format` + Examples: `corgea deps diff --base origin/main --format json`; `corgea deps diff --base HEAD . --fail-on-new high` +- `corgea deps sbom [PATH]` — Generate an SBOM. Flags: `--format`, `--out` + Examples: `corgea deps sbom --format cyclonedx`; `corgea deps sbom --format cyclonedx --out bom.json` +- `corgea deps policy init [PATH]` — Write a starter `.corgea/deps.yml` policy file. Flags: `--exist-ok`, `--format` + Examples: `corgea deps policy init`; `corgea deps policy init --exist-ok --format quiet` + +Notes: `deps scan --out-format table|json|sarif` is the report/export selector; do not combine it with `deps scan --format`. + ## Common Workflows diff --git a/src/deps/mod.rs b/src/deps/mod.rs index dc642d2..91a6790 100644 --- a/src/deps/mod.rs +++ b/src/deps/mod.rs @@ -12,6 +12,7 @@ pub mod parse; pub mod policy; pub mod report; pub mod run; +pub mod skill; use std::path::{Path, PathBuf}; diff --git a/src/deps/run.rs b/src/deps/run.rs index 6383d07..497ceee 100644 --- a/src/deps/run.rs +++ b/src/deps/run.rs @@ -15,6 +15,9 @@ use crate::deps::{scan, DepsError}; #[derive(Subcommand, Debug, Clone)] pub enum DepsSubcommand { /// Scan manifests and lockfiles, build inventory, evaluate policy + #[command( + after_help = "Examples:\n corgea deps scan --format agent\n corgea deps scan --format quiet --fail-on high\n corgea deps scan --out-format sarif --out-file deps.sarif" + )] Scan { #[arg(default_value = ".")] path: String, @@ -28,6 +31,9 @@ pub enum DepsSubcommand { render: RenderArgs, }, /// Print the dependency graph + #[command( + after_help = "Examples:\n corgea deps graph --format agent\n corgea deps graph tests/fixtures/node-app --format json" + )] Graph { #[arg(default_value = ".")] path: String, @@ -35,6 +41,9 @@ pub enum DepsSubcommand { render: RenderArgs, }, /// Explain why a package is present + #[command( + after_help = "Examples:\n corgea deps explain lodash --format agent\n corgea deps explain left-pad tests/fixtures/node-app --format json" + )] Explain { package: String, #[arg(default_value = ".")] @@ -43,6 +52,9 @@ pub enum DepsSubcommand { render: RenderArgs, }, /// Compare dependency graph against a git ref + #[command( + after_help = "Examples:\n corgea deps diff --base origin/main --format json\n corgea deps diff --base HEAD . --fail-on-new high" + )] Diff { #[arg(long)] base: String, @@ -54,6 +66,9 @@ pub enum DepsSubcommand { render: RenderArgs, }, /// Generate an SBOM + #[command( + after_help = "Examples:\n corgea deps sbom --format cyclonedx\n corgea deps sbom --format cyclonedx --out bom.json" + )] Sbom { #[arg(long, default_value = "cyclonedx")] format: String, @@ -72,6 +87,9 @@ pub enum DepsSubcommand { #[derive(Subcommand, Debug, Clone)] pub enum DepsPolicySubcommand { /// Write a starter `.corgea/deps.yml` policy file + #[command( + after_help = "Examples:\n corgea deps policy init\n corgea deps policy init --exist-ok --format quiet" + )] Init { #[arg(default_value = ".")] path: String, @@ -137,6 +155,11 @@ fn run_inner(sub: DepsSubcommand) -> Result { let root = Path::new(&path); let policy = load_policy(root)?; let inv = scan(root, &policy)?; + let render_format = if out_format.is_some() { + None + } else { + Some(render.resolve()?) + }; let output = if let Some(out_format) = out_format.as_deref() { match ReportFormat::parse(out_format)? { ReportFormat::Table => table_output(&inv), @@ -144,10 +167,13 @@ fn run_inner(sub: DepsSubcommand) -> Result { ReportFormat::Sarif => json_line(to_sarif(&inv)), } } else { - render_scan(&inv, render.resolve()?) + render_scan(&inv, render_format.expect("render format resolved")) }; emit_output(&output, out_file.as_deref())?; + if let Some(format) = render_format { + emit_scan_hints(&path, &inv, format); + } if let Some(threshold) = fail_threshold { if should_fail(&inv, threshold) { @@ -229,7 +255,8 @@ fn run_inner(sub: DepsSubcommand) -> Result { exist_ok, render, } => { - let dir = PathBuf::from(path).join(".corgea"); + let render_format = render.resolve()?; + let dir = PathBuf::from(&path).join(".corgea"); std::fs::create_dir_all(&dir) .map_err(|e| DepsError(format!("create .corgea: {e}")))?; let policy_path = dir.join("deps.yml"); @@ -240,8 +267,9 @@ fn run_inner(sub: DepsSubcommand) -> Result { .map_err(|e| DepsError(format!("write policy: {e}")))?; true }; - let output = render_policy_init(&policy_path, created, render.resolve()?); + let output = render_policy_init(&policy_path, created, render_format); emit_output(&output, None)?; + emit_policy_init_hint(&path, render_format); Ok(0) } }, @@ -290,6 +318,15 @@ impl RenderFormat { None => Ok(Self::Human), } } + + fn as_str(self) -> &'static str { + match self { + Self::Human => "human", + Self::Agent => "agent", + Self::Json => "json", + Self::Quiet => "quiet", + } + } } fn agent_env_detected() -> bool { @@ -326,6 +363,45 @@ fn json_line(value: Value) -> String { format!("{value}\n") } +fn emit_scan_hints(path: &str, inv: &crate::deps::Inventory, format: RenderFormat) { + if format == RenderFormat::Quiet { + return; + } + + let format = format.as_str(); + if let Some(package) = inv + .findings + .iter() + .filter_map(|finding| finding.package.as_ref()) + .map(|package| package.name()) + .find(|name| *name != "project") + { + eprintln!( + "Hint: Run `corgea deps explain {} {} --format {}` to inspect why this package is present.", + shell_word(package), + shell_word(path), + format + ); + } + + eprintln!( + "Hint: Run `corgea deps diff --base origin/main {} --format {}` before merging dependency changes.", + shell_word(path), + format + ); +} + +fn emit_policy_init_hint(path: &str, format: RenderFormat) { + if format == RenderFormat::Quiet { + return; + } + + eprintln!( + "Hint: Run `corgea deps scan {} --format json` to verify the policy.", + shell_word(path) + ); +} + fn render_scan(inv: &crate::deps::Inventory, format: RenderFormat) -> String { match format { RenderFormat::Human => table_output(inv), @@ -572,6 +648,18 @@ fn tsv_cell(value: &str) -> String { value.replace(['\t', '\r', '\n'], " ") } +fn shell_word(value: &str) -> String { + if !value.is_empty() + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-' | ':' | '@')) + { + return value.to_string(); + } + + format!("'{}'", value.replace('\'', "'\\''")) +} + fn load_policy(root: &Path) -> Result { let policy_path = root.join(".corgea").join("deps.yml"); if !policy_path.exists() { diff --git a/src/deps/skill.rs b/src/deps/skill.rs new file mode 100644 index 0000000..cf326b3 --- /dev/null +++ b/src/deps/skill.rs @@ -0,0 +1,185 @@ +use std::path::Path; + +use clap::{Command, CommandFactory, Parser}; + +use crate::deps::run::DepsSubcommand; + +pub const BEGIN_MARKER: &str = ""; +pub const END_MARKER: &str = ""; + +#[derive(Parser)] +#[command( + name = "corgea deps", + about = "Offline dependency inventory and policy checks" +)] +struct DepsSkillCli { + #[command(subcommand)] + command: DepsSubcommand, +} + +struct SkillCommand<'a> { + path: &'a [&'a str], + signature: &'a str, + examples: &'a [&'a str], +} + +const COMMANDS: &[SkillCommand<'_>] = &[ + SkillCommand { + path: &["scan"], + signature: "corgea deps scan [PATH]", + examples: &[ + "corgea deps scan --format agent", + "corgea deps scan --format quiet --fail-on high", + ], + }, + SkillCommand { + path: &["graph"], + signature: "corgea deps graph [PATH]", + examples: &[ + "corgea deps graph --format agent", + "corgea deps graph tests/fixtures/node-app --format json", + ], + }, + SkillCommand { + path: &["explain"], + signature: "corgea deps explain [PATH]", + examples: &[ + "corgea deps explain lodash --format agent", + "corgea deps explain left-pad tests/fixtures/node-app --format json", + ], + }, + SkillCommand { + path: &["diff"], + signature: "corgea deps diff --base [PATH]", + examples: &[ + "corgea deps diff --base origin/main --format json", + "corgea deps diff --base HEAD . --fail-on-new high", + ], + }, + SkillCommand { + path: &["sbom"], + signature: "corgea deps sbom [PATH]", + examples: &[ + "corgea deps sbom --format cyclonedx", + "corgea deps sbom --format cyclonedx --out bom.json", + ], + }, + SkillCommand { + path: &["policy", "init"], + signature: "corgea deps policy init [PATH]", + examples: &[ + "corgea deps policy init", + "corgea deps policy init --exist-ok --format quiet", + ], + }, +]; + +pub fn generated_marked_section() -> String { + format!( + "{BEGIN_MARKER}\n{}\n{END_MARKER}", + generated_deps_skill_section().trim_end() + ) +} + +pub fn generated_deps_skill_section() -> String { + let root = DepsSkillCli::command(); + let mut out = String::new(); + out.push_str("### Deps \u{2014} `corgea deps `\n\n"); + out.push_str( + "Offline dependency inventory and policy checks. No Corgea token or network required.\n", + ); + out.push_str( + "Agent environments default to compact TSV; force output with `--format human|agent|json|quiet`.\n\n", + ); + + for spec in COMMANDS { + let command = find_command(&root, spec.path); + let about = command + .get_about() + .map(|about| about.to_string()) + .unwrap_or_else(|| "No description".to_string()); + let flags = important_flags(command); + + out.push_str(&format!("- `{}` \u{2014} {}", spec.signature, about)); + if !flags.is_empty() { + out.push_str(&format!(". Flags: {}", flags.join(", "))); + } + out.push('\n'); + out.push_str(" Examples: "); + out.push_str( + &spec + .examples + .iter() + .map(|example| format!("`{example}`")) + .collect::>() + .join("; "), + ); + out.push('\n'); + } + + out.push_str( + "\nNotes: `deps scan --out-format table|json|sarif` is the report/export selector; do not combine it with `deps scan --format`.\n", + ); + out +} + +pub fn check_skill_file(path: &Path) -> Result<(), String> { + let current = + std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; + let expected = replace_generated_section(¤t)?; + if current == expected { + return Ok(()); + } + + Err(format!( + "{} is out of date. Run `cargo run --example deps_skill -- update`.", + path.display() + )) +} + +pub fn update_skill_file(path: &Path) -> Result<(), String> { + let current = + std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; + let updated = replace_generated_section(¤t)?; + std::fs::write(path, updated).map_err(|e| format!("write {}: {e}", path.display())) +} + +fn replace_generated_section(content: &str) -> Result { + let start = content + .find(BEGIN_MARKER) + .ok_or_else(|| format!("missing {BEGIN_MARKER}"))?; + let end = content + .find(END_MARKER) + .ok_or_else(|| format!("missing {END_MARKER}"))?; + if end < start { + return Err(format!("{END_MARKER} appears before {BEGIN_MARKER}")); + } + + let after_end = end + END_MARKER.len(); + Ok(format!( + "{}{}{}", + &content[..start], + generated_marked_section(), + &content[after_end..] + )) +} + +fn find_command<'a>(root: &'a Command, path: &[&str]) -> &'a Command { + let mut current = root; + for part in path { + current = current + .get_subcommands() + .find(|command| command.get_name() == *part) + .unwrap_or_else(|| panic!("missing deps skill command metadata: {part}")); + } + current +} + +fn important_flags(command: &Command) -> Vec { + command + .get_arguments() + .filter_map(|arg| arg.get_long()) + .filter(|long| *long != "help" && *long != "version") + .map(|long| format!("`--{long}`")) + .collect() +} diff --git a/tests/cli_deps.rs b/tests/cli_deps.rs index 49ba9c4..0d15664 100644 --- a/tests/cli_deps.rs +++ b/tests/cli_deps.rs @@ -138,6 +138,21 @@ fn cli_scan_format_json_outputs_parseable_inventory() { serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON"); assert!(parsed.get("nodes").is_some()); assert!(parsed.get("findings").is_some()); + assert!( + !String::from_utf8_lossy(&out.stdout).contains("Hint:"), + "stdout: {}", + String::from_utf8_lossy(&out.stdout) + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("Hint: Run `corgea deps explain"), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("Hint: Run `corgea deps diff"), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); } #[test] @@ -159,6 +174,32 @@ fn cli_scan_format_quiet_suppresses_stdout_and_preserves_fail_code() { assert_eq!(out.stdout, b""); } +#[test] +fn cli_scan_agent_hints_go_to_stderr_only() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args(["deps", "scan", &fixture("node-app"), "--format", "agent"]) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stdout.starts_with("record\troot\t"), "stdout: {stdout}"); + assert!(!stdout.contains("Hint:"), "stdout: {stdout}"); + assert!( + stderr.contains("Hint: Run `corgea deps explain"), + "stderr: {stderr}" + ); + assert!( + stderr.contains("Hint: Run `corgea deps diff"), + "stderr: {stderr}" + ); +} + #[test] fn cli_graph_format_json_outputs_parseable_nodes() { let (mut cmd, _home) = corgea_isolated(); @@ -179,6 +220,82 @@ fn cli_graph_format_json_outputs_parseable_nodes() { .any(|node| node["id"] == "pkg:npm/left-pad@1.3.0")); } +#[test] +fn cli_deps_help_includes_copy_paste_examples() { + let cases = [ + ( + vec!["deps", "scan", "--help"], + vec![ + "Examples:", + "corgea deps scan --format agent", + "corgea deps scan --out-format sarif --out-file deps.sarif", + ], + ), + ( + vec!["deps", "graph", "--help"], + vec![ + "Examples:", + "corgea deps graph --format agent", + "corgea deps graph tests/fixtures/node-app --format json", + ], + ), + ( + vec!["deps", "explain", "--help"], + vec![ + "Examples:", + "corgea deps explain lodash --format agent", + "corgea deps explain left-pad tests/fixtures/node-app --format json", + ], + ), + ( + vec!["deps", "diff", "--help"], + vec![ + "Examples:", + "corgea deps diff --base origin/main --format json", + "corgea deps diff --base HEAD . --fail-on-new high", + ], + ), + ( + vec!["deps", "sbom", "--help"], + vec![ + "Examples:", + "corgea deps sbom --format cyclonedx", + "corgea deps sbom --format cyclonedx --out bom.json", + ], + ), + ( + vec!["deps", "policy", "init", "--help"], + vec![ + "Examples:", + "corgea deps policy init", + "corgea deps policy init --exist-ok --format quiet", + ], + ), + ]; + + for (args, expected) in cases { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args(args.clone()) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "args: {:?}\nstderr: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + for needle in expected { + assert!( + stdout.contains(needle), + "args: {:?}\nmissing: {needle}\nstdout: {stdout}", + args + ); + } + } +} + #[test] fn cli_deps_rejects_invalid_render_format() { let (mut cmd, _home) = corgea_isolated(); @@ -366,6 +483,16 @@ fn cli_policy_init_exist_ok_preserves_existing_policy() { let parsed: serde_json::Value = serde_json::from_slice(&init_out.stdout).expect("stdout must be valid JSON"); assert_eq!(parsed["created"], false); + assert!( + !String::from_utf8_lossy(&init_out.stdout).contains("Hint:"), + "stdout: {}", + String::from_utf8_lossy(&init_out.stdout) + ); + assert!( + String::from_utf8_lossy(&init_out.stderr).contains("Hint: Run `corgea deps scan"), + "stderr: {}", + String::from_utf8_lossy(&init_out.stderr) + ); assert_eq!( std::fs::read_to_string(&policy_path).expect("read existing policy"), "custom: true\n" diff --git a/tests/cli_deps_skill.rs b/tests/cli_deps_skill.rs new file mode 100644 index 0000000..3bbf897 --- /dev/null +++ b/tests/cli_deps_skill.rs @@ -0,0 +1,10 @@ +use std::path::Path; + +#[test] +fn generated_deps_skill_block_is_current() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("skills") + .join("corgea") + .join("SKILL.md"); + corgea::deps::skill::check_skill_file(&path).expect("deps skill block should be current"); +} From f95c22d560f797827e660e2ad2950a6434e2ce71 Mon Sep 17 00:00:00 2001 From: juangaitanv Date: Mon, 8 Jun 2026 13:03:31 +0200 Subject: [PATCH 9/9] Keep deps policy init to supported fields --- src/deps/policy.rs | 7 ------- src/deps/tests/policy_tests.rs | 14 +++++++++----- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/deps/policy.rs b/src/deps/policy.rs index 253455d..782286a 100644 --- a/src/deps/policy.rs +++ b/src/deps/policy.rs @@ -87,13 +87,6 @@ impl Policy { fail_on_wildcard: true fail_on_latest: true warn_on_semver_range: true - allow_exact_versions: true - transitive_dependencies: - allow_ranges_if_lockfile_resolves: true - fail_if_unresolved: true - ci: - fail_on_new_findings_only: true - severity_threshold: high "# } } diff --git a/src/deps/tests/policy_tests.rs b/src/deps/tests/policy_tests.rs index 83f4c9d..ed3b12b 100644 --- a/src/deps/tests/policy_tests.rs +++ b/src/deps/tests/policy_tests.rs @@ -8,7 +8,7 @@ fn default_policy_fails_on_wildcard() { } #[test] -fn policy_from_yaml_parses_prd_example() { +fn policy_from_yaml_parses_supported_example() { let yaml = r#" dependency_policy: require_lockfile: true @@ -18,14 +18,18 @@ dependency_policy: fail_on_wildcard: true fail_on_latest: true warn_on_semver_range: true - allow_exact_versions: true - ci: - fail_on_new_findings_only: true - severity_threshold: high "#; assert!(Policy::from_yaml(yaml).is_ok()); } +#[test] +fn default_yaml_only_contains_supported_fields() { + let yaml = Policy::default_yaml(); + assert!(!yaml.contains("allow_exact_versions")); + assert!(!yaml.contains("transitive_dependencies")); + assert!(!yaml.contains("ci:")); +} + #[test] fn policy_disabling_rule_silences_finding() { let yaml = r#"