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
8 changes: 8 additions & 0 deletions Cargo.lock

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

13 changes: 13 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[[bin]]
name = "corgea"
path = "src/main.rs"

[features]
# Compiles the in-crate vuln-api test stub (`vuln_api_stub`). Enabled for all
# test builds via the self dev-dependency below; never part of release builds.
test-stub = []

[dev-dependencies]
corgea = { path = ".", features = ["test-stub"] }

[dependencies]
clap = { version = "4.4.13", features = ["derive"] }
dirs = "5.0.1"
Expand All @@ -19,6 +31,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
6 changes: 6 additions & 0 deletions harness
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,12 @@ $output"
}

cmd_pre_commit() {
# git exports GIT_DIR/GIT_INDEX_FILE/… to hooks. From a linked
# worktree GIT_DIR is absolute, so any `git init`/`git add` a test
# spawns in a tempdir would resolve to the shared gitdir and
# corrupt the real repo. Scrub the hook env before running tests.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \
GIT_COMMON_DIR GIT_PREFIX
local staged; staged="$(staged_rs_files)"
if [ -z "$staged" ]; then
printf "No staged Rust files — skipping checks\n"
Expand Down
97 changes: 97 additions & 0 deletions skills/corgea/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,103 @@ 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`.

### Install Wrappers — `corgea pip|npm|yarn|pnpm|uv <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 for known-vulnerable or malicious package records. Public mode
needs no token and fails open on vulnerability-service outages. Authenticated mode
uses the configured Corgea token against the default vuln-api and fails closed when a
Corgea verdict cannot be obtained. OSV is queried as a secondary public source; an
OSV finding blocks, but an OSV clean result never weakens a Corgea fail-closed result.
Everything else passes through with the package manager's own exit code. Git/URL/path
specs are noted, never blocked. Bare `npm install` (zero specs, `package.json` present)
is gated too: the full lockfile-resolved tree is verdicted, so a vulnerable lockfile blocks. Bare
`yarn`/`pnpm`/`uv` installs have no safe dry-run; they run unchecked after a stderr note
(`note: bare '<pm> <sub>' is not gated …`). `-r requirements.txt` files get a printed
note when the tree pass doesn't cover them.

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.

When vulnerability checks are enabled, the gate covers the **full would-install set**
where safe, not just the named targets: `pip` and `npm` resolve the complete tree (named + transitive) via a
safe dry-run (`pip install --dry-run …`; an isolated `npm install --package-lock-only`
in a temp dir, never touching your lockfile) and verdict every package, so a flagged
**transitive** dependency blocks the install too. `yarn`, `pnpm`, and `uv` have no safe
dry-run, so they verify the named targets only and print
`warning: transitive dependencies not checked (…); only named packages were verified.`
The same warning is emitted (and the gate falls back to named-only) whenever a pip/npm
dry-run fails. Verdict requests run in a bounded pool (8 parallel).

```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 --json install newpkg # machine-readable per-target report incl. verdicts
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/unverifiable blocks. |
| `--force` | | Proceed despite all findings (vulnerable, unverifiable, recent). Findings still print. |
| `--json` | | JSON report instead of text. Per-result `verdict` object + `verdict_mode` + `tree`. |

`--json` adds a `tree` object: `null` when no tree pass ran; otherwise `mode` is `"full"`
(transitive checked) or `"named-only"` (with a `reason`), plus `resolved_count` and a
`transitive[]` array of `{name, version, verdict}` for packages beyond the named targets.
Vulnerable `verdict` objects carry a `remediation` field: the safe version covering
every advisory, or `null` when any advisory has no known fix.

Recency and public vulnerability gating need no token. `CORGEA_VULN_API_URL` is public
by default even when `CORGEA_TOKEN` is set; set
`CORGEA_VULN_API_SEND_TOKEN_TO_CUSTOM_URL=1` to forward the token to a custom URL and
make that path fail closed. Overrides for testing: `CORGEA_PYPI_REGISTRY`,
`CORGEA_NPM_REGISTRY`, `CORGEA_VULN_API_URL`, `CORGEA_OSV_API_URL`.

#### Testing the gate

Staging vuln-api (`CORGEA_VULN_API_URL=https://cve-worker-staging.corgea.workers.dev`)
serves deterministic verdicts for dogfooding. It ignores auth — any non-empty
`CORGEA_TOKEN` value enables full-gate mode. 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_TOKEN=dogfood-dummy \
CORGEA_VULN_API_URL=https://cve-worker-staging.corgea.workers.dev \
corgea npm install axios@0.21.0
```

Expected output (exit code 1; nothing is installed):

```
Pre-checking `npm install axios@0.21.0` (threshold 2d)
1 ok, 0 recent, 1 vulnerable, 0 unverifiable, 0 skipped, 0 errors
tree: 2 packages resolved, 1 transitive checked
✗ axios@0.21.0 → axios@0.21.0 known vulnerable:
CVE-2021-3749 (high) — fixed in 0.21.2
CVE-2020-28168 (medium) — fixed in 0.21.1
→ safe version: axios@0.21.2
Refusing to run install. Pass --force to proceed despite findings.
```

Caveat: the staging PyPI seed covers recent CVEs only. Decade-old classics
(`pyyaml==5.1`, `django==2.2`) return clean **by design** — a clean verdict on
those does not mean the gate is broken.

<!-- BEGIN GENERATED CORGEA DEPS SKILL -->
### Deps — `corgea deps <command>`

Expand Down
14 changes: 12 additions & 2 deletions src/authorize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ pub fn run(scope: Option<String>, url: Option<String>) -> Result<(), Box<dyn std
fn find_available_port(start_port: u16) -> Result<u16, Box<dyn std::error::Error>> {
// Try a more reliable approach - start from a higher range that's less likely to be used
let search_ranges = vec![
(start_port, start_port + 50),
// Saturate: a start port near u16::MAX must clamp, not overflow.
(start_port, start_port.saturating_add(50)),
(9000, 9100),
(8000, 8100),
(7000, 7100),
Expand Down Expand Up @@ -632,7 +633,16 @@ mod tests {

assert!(!port_is_available(port));
drop(listener);
assert!(port_is_available(port));
// The freed port returns to the OS ephemeral pool, where a parallel
// test's `bind(":0")` can snatch it before the re-check — so accept
// any of several freshly freed ports reading available. The chain is
// lazy: fresh ports are only reserved after a collision.
assert!(
std::iter::once(port)
.chain((0..4).map(|_| reserve_ephemeral_port()))
.any(port_is_available),
"five consecutive freed ports all read unavailable"
);
}

#[test]
Expand Down
49 changes: 49 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,53 @@ impl Config {

self.debug
}

/// Base URL for the vuln-api service: `CORGEA_VULN_API_URL` env var,
/// then the public default.
pub fn get_vuln_api_url(&self) -> String {
crate::utils::generic::get_env_var_if_exists("CORGEA_VULN_API_URL")
.unwrap_or_else(|| "https://vuln-api.corgea.app".to_string())
.trim()
.trim_end_matches('/')
.to_string()
}
}

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

fn test_config() -> Config {
Config {
url: "https://www.corgea.app".to_string(),
debug: 0,
token: "".to_string(),
}
}

/// All `get_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 get_vuln_api_url_resolution_order() {
env::remove_var("CORGEA_VULN_API_URL");

// Default when the env var is unset.
assert_eq!(
test_config().get_vuln_api_url(),
"https://vuln-api.corgea.app"
);

// Env var wins; whitespace and trailing slash trimmed.
env::set_var("CORGEA_VULN_API_URL", " https://env.example.com/ ");
assert_eq!(test_config().get_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!(
test_config().get_vuln_api_url(),
"https://vuln-api.corgea.app"
);
env::remove_var("CORGEA_VULN_API_URL");
}
}
5 changes: 4 additions & 1 deletion src/deps/ecosystems/pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,10 @@ fn exact_version_from_declared(name: &str, declared: &str) -> Option<String> {
Some(declared.trim_start_matches('=').trim().to_string())
}

fn normalize_pypi_name(name: &str) -> String {
/// PEP 503 name normalization: lowercase, runs of `-`/`_`/`.` collapse to `-`.
/// Also used by the install gate (`precheck`) so both features share one
/// canonical pypi name form.
pub(crate) fn normalize_pypi_name(name: &str) -> String {
let mut out = String::new();
let mut last_was_separator = false;
for c in name.trim().chars() {
Expand Down
13 changes: 13 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1 +1,14 @@
pub mod deps;
pub mod osv;
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.
mod log;
pub mod vuln_api;
// Test-only HTTP stub for the vuln-api. Gated out of release builds; the
// `test-stub` feature is enabled for every test build by the self
// dev-dependency in Cargo.toml, so integration tests can use it too.
#[cfg(any(test, feature = "test-stub"))]
pub mod vuln_api_stub;
Loading
Loading