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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dirs = "5.0.1"
reqwest = { version = "0.12.23", default-features = false, features = [
"blocking",
"cookies",
"gzip",
"json",
"multipart",
"native-tls",
Expand All @@ -31,6 +32,7 @@ reqwest = { version = "0.12.23", default-features = false, features = [
toml = "0.8.8"
log = "0.4"
env_logger = "0.11"
semver = "1"
serde = { version = "1.0.195", features = ["derive"] }
serde_json = "1.0.111"
serde_derive = "1.0.195"
Expand Down
79 changes: 79 additions & 0 deletions skills/corgea/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,85 @@ Agent environments default to compact TSV; force output with `--format human|age
Notes: `deps scan --out-format table|json|sarif` is the report/export selector; do not combine it with `deps scan --format`.
<!-- END GENERATED CORGEA DEPS SKILL -->

### Install Wrappers — `corgea pip|npm <args...>`

Run a package manager through Corgea's install gate. Install commands with
named targets are resolved against the public registry first, then gated
twice: a version published within `--threshold` (default `2d`) blocks
(exit 1), and each resolved version is checked against Corgea's vuln-api —
known-vulnerable or malicious versions block. CVE checks are public and need
no token; vuln-api lookup outages warn and continue (fail-open). Everything
else passes through with the package manager's own exit code. Git/URL/path
specs (including `pip install .`, PEP 508 `name @ url` direct references, and
npm GitHub shorthand `user/repo`) are noted, never blocked. The install verb
is found behind global flags (`npm --loglevel silent install x` is still
gated). Bare installs (no named targets) and `-r requirements.txt` files are
noted, not gated. `npm ci` passes through ungated.

Wrapper flags (`--force`, `--no-fail`, `-t`) are read between the manager
name and the install verb (`corgea npm --force install x`); flags after the
verb belong to the package manager and are forwarded untouched.

Blocked findings steer to the fix: each advisory line shows
`fixed in <version>` (or `no fixed version known`). When every advisory on a
package has a fix, the gate prints `→ safe version: <name>@<version>` — the
highest fix covering every advisory. Install that version instead.

```bash
corgea pip install requests==2.31.0 # resolves, checks recency + vuln verdict, then runs pip
corgea npm install axios@^1.0.0 # same gate for npm ranges
corgea pip --no-fail install newpkg # demote a recency block to a warning (vuln blocks still apply)
corgea pip --force install badpkg # print findings but install anyway (overrides every block)
corgea pip list # non-install subcommands pass straight through
```

| Flag | Short | Description |
|------|-------|-------------|
| `--threshold` | `-t` | Recency threshold (`2d`, `12h`). Younger resolved versions block. |
| `--no-fail` | | Demote a recency block to a warning. Does NOT bypass vulnerable blocks. |
| `--force` | | Proceed despite all findings (vulnerable, recent). Findings still print. |

Overrides for testing: `CORGEA_PYPI_REGISTRY`, `CORGEA_NPM_REGISTRY`,
`CORGEA_VULN_API_URL`.

#### Limitations

The gate is a wrapper, not an enforcement boundary. By design it cannot catch:

- **Direct invocation** — running the package manager itself (`pip`, `npm`,
`python -m pip`) skips the gate entirely.
- **Custom indexes/registries** — `--index-url`, `--registry`, and `.npmrc`/
`pip.conf` overrides change where packages resolve from. The gate still
verdicts each `name@version`, but it cannot vouch that a substituted
registry serves the same artifact those advisories describe.
- **Transitive dependencies** — only the named install targets are verified;
the rest of the resolved tree installs unchecked.
- **Bare installs and lockfiles** — `npm install` with no targets, `npm ci`,
and `-r requirements.txt` files run unchecked after a note.

Hard enforcement needs org-level controls — lockfile review, registry
allow-listing — alongside the wrapper.

#### Testing the gate

The staging vuln-api (`https://cve-worker-staging.corgea.workers.dev`) is the
current default endpoint and serves deterministic verdicts for dogfooding.
Known-vulnerable targets:

| Ecosystem | Target | Verdict |
|-----------|--------|---------|
| npm | `axios@0.21.0` | vulnerable — fixed in 0.21.2 |
| npm | `minimist@0.0.8` | vulnerable — fixed in 1.2.2 |
| npm | `node-fetch@2.6.0` | vulnerable — fixed in 2.6.7 |
| PyPI | `mezzanine==6.0.0` | vulnerable — no fixed version known |

Verify the gate end-to-end:

```bash
corgea npm install axios@0.21.0 # exit 1, names CVE-2021-3749, steers to 0.21.2
corgea pip install mezzanine==6.0.0 # exit 1, no fixed version known
```

## Common Workflows

### Scan full project
Expand Down
37 changes: 37 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::{env, fs, io};

pub const DEFAULT_VULN_API_URL: &str = "https://cve-worker-staging.corgea.workers.dev";

#[derive(Serialize, Deserialize, Clone)]
pub struct Config {
pub(crate) url: String,
Comment thread
juangaitanv marked this conversation as resolved.
Expand Down Expand Up @@ -101,3 +103,38 @@ impl Config {
self.debug
}
}

/// Base URL for the vuln-api service: `CORGEA_VULN_API_URL` env var,
/// then the public default. Pure env/constant — no config file field.
pub fn vuln_api_url() -> String {
crate::utils::generic::get_env_var_if_exists("CORGEA_VULN_API_URL")
.unwrap_or_else(|| DEFAULT_VULN_API_URL.to_string())
.trim()
.trim_end_matches('/')
.to_string()
}

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

/// All `vuln_api_url` cases in one test fn: the env-var cases
/// mutate process-global state, so they must not run concurrently
/// with each other under the parallel test harness.
#[test]
fn vuln_api_url_resolution_order() {
env::remove_var("CORGEA_VULN_API_URL");

// Default when the env var is unset.
assert_eq!(vuln_api_url(), DEFAULT_VULN_API_URL);

// Env var wins; whitespace and trailing slash trimmed.
env::set_var("CORGEA_VULN_API_URL", " https://env.example.com/ ");
assert_eq!(vuln_api_url(), "https://env.example.com");

// Empty / whitespace-only env var is treated as unset.
env::set_var("CORGEA_VULN_API_URL", " ");
assert_eq!(vuln_api_url(), DEFAULT_VULN_API_URL);
env::remove_var("CORGEA_VULN_API_URL");
}
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
pub mod deps;
pub mod precheck;
pub mod verify_deps;
// Also declared in the binary crate (src/main.rs); re-declared here so library modules
// (e.g. vuln_api) can use `crate::log::debug`. src/log.rs is a thin `::log` facade that
// compiles cleanly in both crates.
Expand Down
63 changes: 63 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,57 @@ enum Commands {
#[command(subcommand)]
command: corgea::deps::run::DepsSubcommand,
},
/// Wrap `npm` commands: gate install targets on recency + vuln verdicts, then run npm.
Npm(InstallWrapArgs),
/// Wrap `pip` commands: gate install targets on recency + vuln verdicts, then run pip.
Pip(InstallWrapArgs),
}

/// Shared flags for the install-wrapper subcommands (`corgea npm|pip`).
#[derive(clap::Args, Debug, Clone)]
struct InstallWrapArgs {
#[arg(
long,
short = 't',
default_value = "2d",
value_parser = corgea::verify_deps::parse_threshold,
help = "Recency threshold. Resolved versions younger than this are blocked. e.g. '2d', '12h'."
)]
threshold: std::time::Duration,

#[arg(
long,
help = "Demote a recency block to a printed warning. The install still runs."
)]
no_fail: bool,

#[arg(
long,
help = "Proceed with the install despite vulnerable or recent findings. Findings are still printed."
)]
force: bool,

/// Arguments forwarded to the package manager (subcommand and package specs).
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
cmd: Vec<String>,
}

fn install_wrap_options(args: &InstallWrapArgs) -> corgea::precheck::PrecheckOptions {
corgea::precheck::PrecheckOptions {
threshold: args.threshold,
no_fail: args.no_fail,
force: args.force,
verdict: Some(corgea::precheck::VerdictConfig {
base_url: config::vuln_api_url(),
}),
npm_registry: utils::generic::get_env_var_if_exists("CORGEA_NPM_REGISTRY"),
pypi_registry: utils::generic::get_env_var_if_exists("CORGEA_PYPI_REGISTRY"),
}
}

fn run_install_wrap_command(manager: corgea::precheck::PackageManager, args: &InstallWrapArgs) {
let code = corgea::precheck::run_install(manager, &args.cmd, install_wrap_options(args));
std::process::exit(code);
}

#[derive(Subcommand, Debug, Clone, PartialEq)]
Comment thread
juangaitanv marked this conversation as resolved.
Expand Down Expand Up @@ -504,7 +555,19 @@ fn main() {
// Offline: no token / network. Exit code propagates fail-on policy.
std::process::exit(i32::from(corgea::deps::run::run(command.clone())));
}
// Install wrappers: no auth gate. Public CVE checks run without a
// token and fail open on lookup outages.
Some(Commands::Npm(args)) => {
run_install_wrap_command(corgea::precheck::PackageManager::Npm, args)
}
Some(Commands::Pip(args)) => {
run_install_wrap_command(corgea::precheck::PackageManager::Pip, args)
}
None => {
if let Some(message) = corgea::precheck::pip3_alias_message(&cli.args) {
eprintln!("{message}");
std::process::exit(1);
}
utils::terminal::show_welcome_message();
let _ = Cli::command().print_help();
println!();
Expand Down
65 changes: 65 additions & 0 deletions src/precheck/exec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//! Resolve and exec the real package manager, forwarding args and exit codes.

use std::ffi::OsString;
use std::process::Command;

use super::PackageManager;

pub(super) fn exec_install_with_args(
manager: PackageManager,
subcommand: &str,
rest: &[String],
) -> i32 {
let mut full = Vec::with_capacity(rest.len() + 1);
full.push(subcommand.to_string());
full.extend(rest.iter().cloned());
exec_command(manager.binary_name(), &full)
}

/// Resolve `binary` on PATH. On Windows this finds `.cmd` shims. pip is the
/// one manager with a conventional alias, so a missing `pip` retries `pip3`.
/// The error names the binary and any fallback tried.
pub(super) fn resolve_binary(binary: &str) -> Result<std::path::PathBuf, String> {
if let Ok(p) = which::which(binary) {
return Ok(p);
}
if binary == "pip" {
if let Ok(p) = which::which("pip3") {
return Ok(p);
}
return Err("error: 'pip' not found on PATH (also tried 'pip3')".to_string());
}
Err(format!("error: '{binary}' not found on PATH"))
}

pub(super) fn exec_command(binary: &str, args: &[String]) -> i32 {
let resolved = match resolve_binary(binary) {
Ok(p) => p,
Err(msg) => {
eprintln!("{msg}");
return 127;
}
};

let os_args: Vec<OsString> = args.iter().map(OsString::from).collect();

let mut command = Command::new(&resolved);
command.args(&os_args);
match command.status() {
Ok(status) => status.code().unwrap_or_else(|| {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = status.signal() {
return 128 + sig;
}
}
1
}),
Err(e) => {
// Name the resolved path: it may be the pip3 fallback, not `binary`.
eprintln!("failed to exec {}: {}", resolved.display(), e);
1
}
}
}
Loading
Loading