dennisdoomen / dennisdoomen/packageguard
[Feature]: Allow policies to gate on risk scores and package age
- Dominant language
- C#
- Stars
- 73
- Forks
- 3
- Avg merge
- 3h
- Merged PRs (30d)
- 20
Description
### Background and motivation
PackageGuard computes a detailed, well-reasoned risk score for every package across legal, security and operational dimensions, but that score cannot influence the outcome of a run. It is presented in the console and in the HTML/SARIF reports, and that is where it stops. The allow/deny lists can only reason about package identity, version and license.
This is a shame, because the risk data answers questions that a name-based deny list never can: *is this package abandoned, unsigned, maintained by a single personal account, and shipping native binaries?* Teams currently have to read the report and act manually.
### Proposal
Let policies gate on risk. Rules include:
- **Per-dimension thresholds**: `maxOverallRisk`, plus `maxLegalRisk`, `maxSecurityRisk` and `maxOperationalRisk`. The overall score is a weighted blend, so a package can be operationally awful and still land under an overall threshold — per-dimension gating is more expressive than a single number.
- **Cooldown / minimum package age**: `minPackageAgeDays` blocks packages published within the last N days. This is cheap to implement and is the single most effective defence against the typical npm account-compromise pattern, where a malicious version is published and yanked within hours. It is configurable per-ecosystem, since npm and NuGet have very different risk profiles here.
- **Max OSV severity score**: cap on the highest OSV CVSS score found for a package (e.g. `maxOsvSeverityScore: 7.0` to deny anything CVSS ≥ 7.0/"High"), independent of the blended security risk score. OSV reports a numeric CVSS score (`OsvVulnerabilityRecord.Severity` is already a `double` in the codebase) rather than a named level, so the threshold is numeric rather than a `"HIGH"`/`"CRITICAL"` string. A direct, legible knob for "no known criticals."
- **Factor-level rules**: beyond scores, some individual signals are strong enough to warrant a direct rule: `denyUnsigned: true`, `denyDeprecated: true`, `denyWithoutRepository: true`. These are more legible in a config file than a numeric threshold, and easier to justify to a security team.
- **Explicit package exclusion**: an allow-list style override so a specific package (optionally pinned to a version/version range) can be excluded from risk-based denial even if it exceeds thresholds or has known vulnerabilities. Needed for accepted-risk exceptions (e.g. a vetted package with a won't-fix CVE) without loosening the policy globally.
#### Example configuration
```json
{
"settings": {
"deny": {
"maxOverallRisk": 60,
"maxLegalRisk": 5,
"maxSecurityRisk": 7,
"maxOperationalRisk": 8,
"maxOsvSeverityScore": 7.0,
"denyUnsigned": true,
"denyDeprecated": true,
"denyWithoutRepository": true,
"minPackageAgeDays": {
"npm": 14,
"nuget": 3
}
},
"riskExceptions": [
{
"package": "left-pad",
"reason": "Vetted manually on 2026-01-15, won't-fix CVE-2025-12345 does not apply to our usage",
"expiresOn": "2026-07-01"
},
{
"package": "some-native-lib",
"versions": "[1.0.0,2.0.0)",
"reason": "Signing certificate expired but publisher identity verified out-of-band"
}
]
}
}
```
### Details worth discussing
- **Auto-enrichment**: risk enrichment (GitHub/OSV/registry lookups) involves network calls. If a policy contains any risk rule (dimension thresholds, package age, OSV severity cap, etc.), enrichment must run automatically — even without `--report-risk` — since there'd otherwise be no data to gate on. This should be automatic but clearly communicated, and the existing risk cache with its TTL should absorb the added cost.
- **Violation reporting**: a risk-based violation needs to explain itself. `PolicyViolation` currently carries package, version, license, projects and feed; it needs to carry the triggering factor and its rationale, which `RiskFactorContribution` already holds.
- **Non-determinism**: a build could pass today and fail tomorrow because a new CVE was published. That is arguably correct behaviour, but it must be a deliberate, documented choice, and it strengthens the case for a baseline/exception mechanism (see `riskExceptions` above) as a companion feature.
### Amendment: generalize "warn-only" beyond risk rules
The "Alternative Concerns" section below already floats making risk rules warn-only by default. Broaden that into a general mechanism that also applies to the existing `allow`/`deny` lists (package/license/prerelease matching), not just the new risk-gating rules proposed here:
- **A `warn` policy section**, mirroring `deny` (`packages`, `licenses`, `prerelease`). A match against `warn` logs a warning in the build output instead of failing the build. If a package matches both `deny` and `warn`, `deny` wins.
- **A way to downgrade all `deny` violations to warnings at run time** — e.g. a `--treat-deny-as-warning` CLI flag / `PACKAGEGUARD_DENY_AS_WARNING` environment variable. This covers the zero-day scenario: a new deny rule (or vulnerability) is added, but the team still wants to ship while they work through remediation, without having to edit the config back and forth.
This would apply to both the current allow/deny matching and to whatever risk-based rules land from this issue, so a `maxOsvSeverityScore`/`denyUnsigned`/etc. violation could also be downgraded to a warning the same way.
```json
{
"settings": {
"warn": {
"licenses": ["GPL-3.0"],
"packages": ["some-package"]
}
}
}
```
This could ship as its own smaller PR ahead of the risk-gating work, since it's independently useful.
### Alternative Concerns
- Teams can read the HTML report and manually add offenders to the deny list, but this does not scale and lags reality.
- A single `maxOverallRisk` number would be simpler, but hides exactly the cases most worth blocking.
- Making risk rules warn-only by default would avoid surprise build breaks, at the cost of being ignorable.
### Are you willing help with a pull-request?
No
Contributor guide
Research direction
Start by reading the existing PolicyViolation, RiskFactorContribution, and OsvVulnerabilityRecord types, then trace the current policy evaluation and risk-enrichment paths. The scope needs to be agreed before implementation: risk thresholds, package-age and exception handling, violation explanations, and the generalized warn-only behavior should all have defined configuration and completed behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100