Skip to content

Commit 58ed2e3

Browse files
authored
feat: Heavily improve machine readable evidence and expose more evidence (#6)
1 parent fdf780a commit 58ed2e3

20 files changed

Lines changed: 923 additions & 97 deletions

File tree

README.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
- `allow`: `true` or `false`
3434
- `risk`: `low | medium | high | critical`
3535
- `reasons`: human-readable findings
36+
- `evidence`: structured findings (`kind`, stable `id`, `severity`, `message`, `facts`)
3637
- `metadata`: package context (latest, publish date, downloads, advisories)
3738

3839
Policy can be extended with `custom_rules` in config (see `docs/configuration-spec.md`).
@@ -53,7 +54,6 @@ Prioritized planned work:
5354
### Now
5455

5556
- [ ] Shared registry HTTP utilities (retry/backoff/rate-limit handling/user-agent/error mapping)
56-
- [ ] Structured reasons in responses (`check_id`, `rule_id`, machine-readable evidence)
5757
- [ ] Transitive dependency path visibility in lockfile audits
5858
- [ ] Deterministic policy snapshots in audit logs (config fingerprint + enabled checks)
5959
- [ ] Dependency confusion defenses for internal/private package names
@@ -173,6 +173,20 @@ Windows example (no console window):
173173
"reasons": [
174174
"lodash@3.10.1 is 1 major version behind latest (4.17.21)"
175175
],
176+
"evidence": [
177+
{
178+
"kind": "check",
179+
"id": "staleness.behind_latest",
180+
"severity": "low",
181+
"message": "lodash@3.10.1 is 1 major version behind latest (4.17.21)",
182+
"facts": {
183+
"package_name": "lodash",
184+
"resolved_version": "3.10.1",
185+
"latest_version": "4.17.21",
186+
"major_gap": 1
187+
}
188+
}
189+
],
176190
"metadata": {
177191
"latest": "4.17.21",
178192
"requested": "3.10.1",
@@ -182,6 +196,42 @@ Windows example (no console window):
182196
}
183197
```
184198

199+
`evidence.id` is stable and machine-oriented:
200+
- built-in checks: `<check_id>.<reason_code>` (example: `staleness.behind_latest`)
201+
- custom rules: `custom_rule.<rule_id>` (example: `custom_rule.low-downloads`)
202+
- policy/runtime items keep explicit IDs (example: `denylist.package`, `risk.medium_pair_escalation`)
203+
204+
Example multi-signal evidence excerpt (all entries include `facts`):
205+
206+
```json
207+
{
208+
"evidence": [
209+
{
210+
"id": "version_age.too_new",
211+
"kind": "check",
212+
"severity": "high",
213+
"facts": {
214+
"package_name": "lodash",
215+
"resolved_version": "1.0.2",
216+
"age_days": 1,
217+
"min_age_days": 7
218+
}
219+
},
220+
{
221+
"id": "advisory.known_advisory",
222+
"kind": "check",
223+
"severity": "high",
224+
"facts": {
225+
"advisory_ids": ["OSV-2025-0001"],
226+
"advisory_aliases": ["CVE-2025-9999"],
227+
"requested_version": "1.0.2",
228+
"recommended_fixed_version": "4.17.21"
229+
}
230+
}
231+
]
232+
}
233+
```
234+
185235
## Development
186236

187237
```bash

crates/checks/advisory/src/lib.rs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,28 @@ async fn run(
8585
format!("{package_name}@{requested_version} is affected by {identifiers}")
8686
};
8787

88-
Some(CheckFinding {
89-
severity: Severity::High,
90-
reason,
91-
})
88+
let advisory_ids = advisories
89+
.iter()
90+
.map(|advisory| advisory.id.clone())
91+
.collect::<Vec<_>>();
92+
let mut finding = CheckFinding::new(Severity::High, reason, "known_advisory")
93+
.with_fact("package_name", package_name)
94+
.with_fact("requested_version", requested_version)
95+
.with_fact("latest_version", latest_version)
96+
.with_fact("advisory_ids", advisory_ids)
97+
.with_fact(
98+
"advisory_aliases",
99+
advisories
100+
.iter()
101+
.flat_map(|advisory| advisory.aliases.iter().cloned())
102+
.collect::<Vec<_>>(),
103+
);
104+
105+
if let Some(fixed) = best_fixed_version(&fixed_versions) {
106+
finding = finding.with_fact("recommended_fixed_version", fixed);
107+
}
108+
109+
Some(finding)
92110
}
93111

94112
fn advisory_identifiers(advisory: &PackageAdvisory) -> Vec<String> {

crates/checks/existence/src/lib.rs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,22 @@ impl Check for ExistenceCheck {
5757
}
5858

5959
fn missing_package(package_name: &str) -> CheckFinding {
60-
CheckFinding {
61-
severity: Severity::Critical,
62-
reason: format!("{package_name} does not exist (possible hallucination / slopsquatting)"),
63-
}
60+
CheckFinding::new(
61+
Severity::Critical,
62+
format!("{package_name} does not exist (possible hallucination / slopsquatting)"),
63+
"missing_package",
64+
)
65+
.with_fact("package_name", package_name)
6466
}
6567

6668
fn missing_version(package_name: &str, version: &str) -> CheckFinding {
67-
CheckFinding {
68-
severity: Severity::Critical,
69-
reason: format!("{package_name}@{version} does not exist (possible hallucinated version)"),
70-
}
69+
CheckFinding::new(
70+
Severity::Critical,
71+
format!("{package_name}@{version} does not exist (possible hallucinated version)"),
72+
"missing_version",
73+
)
74+
.with_fact("package_name", package_name)
75+
.with_fact("requested_version", version)
7176
}
7277

7378
#[cfg(test)]

crates/checks/install-script/src/lib.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,18 @@ async fn run(package_name: &str, version: &PackageVersion) -> Option<CheckFindin
5959
.iter()
6060
.find(|script| is_suspicious(script));
6161

62-
suspicious.map(|script| CheckFinding {
63-
severity: Severity::High,
64-
reason: format!(
65-
"{package_name}@{} has a suspicious install hook: {script}",
66-
version.version
67-
),
62+
suspicious.map(|script| {
63+
CheckFinding::new(
64+
Severity::High,
65+
format!(
66+
"{package_name}@{} has a suspicious install hook: {script}",
67+
version.version
68+
),
69+
"suspicious_install_hook",
70+
)
71+
.with_fact("package_name", package_name)
72+
.with_fact("resolved_version", version.version.as_str())
73+
.with_fact("script", script.as_str())
6874
})
6975
}
7076

crates/checks/popularity/src/lib.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,22 @@ async fn run(
6363
return None;
6464
}
6565

66-
Some(CheckFinding {
67-
severity: Severity::High,
68-
reason: format!(
66+
Some(
67+
CheckFinding::new(
68+
Severity::High,
69+
format!(
6970
"{package_name}@{} has low adoption ({downloads} weekly downloads) and is only {age_days} day(s) old",
7071
version.version
7172
),
72-
})
73+
"low_adoption_young_package",
74+
)
75+
.with_fact("package_name", package_name)
76+
.with_fact("resolved_version", version.version.as_str())
77+
.with_fact("weekly_downloads", downloads)
78+
.with_fact("age_days", age_days)
79+
.with_fact("min_weekly_downloads", min_weekly_downloads)
80+
.with_fact("young_package_age_days", young_package_age_days),
81+
)
7382
}
7483

7584
#[cfg(test)]

crates/checks/staleness/src/lib.rs

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -47,27 +47,39 @@ async fn run(
4747
let ignored = is_ignored(package.name.as_str(), requested.version.as_str(), policy);
4848

4949
if requested.deprecated {
50-
findings.push(CheckFinding {
51-
severity: Severity::High,
52-
reason: format!(
53-
"{}@{} is marked deprecated",
54-
package.name, requested.version
55-
),
56-
});
50+
findings.push(
51+
CheckFinding::new(
52+
Severity::High,
53+
format!(
54+
"{}@{} is marked deprecated",
55+
package.name, requested.version
56+
),
57+
"deprecated_version",
58+
)
59+
.with_fact("package_name", package.name.as_str())
60+
.with_fact("resolved_version", requested.version.as_str()),
61+
);
5762
}
5863

5964
if !ignored && let Some(published) = requested.published {
6065
let age_days = chrono::Utc::now()
6166
.signed_duration_since(published)
6267
.num_days();
6368
if age_days >= policy.warn_age_days {
64-
findings.push(CheckFinding {
65-
severity: Severity::Low,
66-
reason: format!(
67-
"{}@{} is {} day(s) old (>= {} days)",
68-
package.name, requested.version, age_days, policy.warn_age_days
69-
),
70-
});
69+
findings.push(
70+
CheckFinding::new(
71+
Severity::Low,
72+
format!(
73+
"{}@{} is {} day(s) old (>= {} days)",
74+
package.name, requested.version, age_days, policy.warn_age_days
75+
),
76+
"old_release_age",
77+
)
78+
.with_fact("package_name", package.name.as_str())
79+
.with_fact("resolved_version", requested.version.as_str())
80+
.with_fact("age_days", age_days)
81+
.with_fact("warn_age_days", policy.warn_age_days),
82+
);
7183
}
7284
}
7385

@@ -94,21 +106,44 @@ async fn run(
94106
};
95107

96108
if major_gap >= policy.warn_major_versions_behind {
97-
findings.push(CheckFinding {
98-
severity: Severity::Medium,
99-
reason: format!(
100-
"{}@{} is {} major version(s) behind latest ({})",
101-
package.name, requested.version, major_gap, package.latest
109+
findings.push(
110+
CheckFinding::new(
111+
Severity::Medium,
112+
format!(
113+
"{}@{} is {} major version(s) behind latest ({})",
114+
package.name, requested.version, major_gap, package.latest
115+
),
116+
"major_versions_behind",
117+
)
118+
.with_fact("package_name", package.name.as_str())
119+
.with_fact("resolved_version", requested.version.as_str())
120+
.with_fact("latest_version", package.latest.as_str())
121+
.with_fact("major_gap", major_gap)
122+
.with_fact(
123+
"warn_major_versions_behind",
124+
policy.warn_major_versions_behind,
102125
),
103-
});
126+
);
104127
} else if major_gap >= 1 || minor_gap >= policy.warn_minor_versions_behind {
105-
findings.push(CheckFinding {
106-
severity: Severity::Low,
107-
reason: format!(
108-
"{}@{} is behind latest ({})",
109-
package.name, requested.version, package.latest
128+
findings.push(
129+
CheckFinding::new(
130+
Severity::Low,
131+
format!(
132+
"{}@{} is behind latest ({})",
133+
package.name, requested.version, package.latest
134+
),
135+
"behind_latest",
136+
)
137+
.with_fact("package_name", package.name.as_str())
138+
.with_fact("resolved_version", requested.version.as_str())
139+
.with_fact("latest_version", package.latest.as_str())
140+
.with_fact("major_gap", major_gap)
141+
.with_fact("minor_gap", minor_gap)
142+
.with_fact(
143+
"warn_minor_versions_behind",
144+
policy.warn_minor_versions_behind,
110145
),
111-
});
146+
);
112147
}
113148

114149
findings

crates/checks/typosquat/src/lib.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,19 @@ async fn run(
8787
return Ok(None);
8888
};
8989

90-
Ok(Some(CheckFinding {
91-
severity: Severity::High,
92-
reason: format!(
90+
Ok(Some(
91+
CheckFinding::new(
92+
Severity::High,
93+
format!(
9394
"{package_name} is {distance} edit(s) away from popular package {candidate} and has low adoption ({weekly_downloads} weekly downloads)"
9495
),
95-
}))
96+
"close_to_popular_name",
97+
)
98+
.with_fact("package_name", package_name)
99+
.with_fact("closest_package", candidate)
100+
.with_fact("edit_distance", distance)
101+
.with_fact("weekly_downloads", weekly_downloads),
102+
))
96103
}
97104

98105
fn bounded_levenshtein(lhs: &str, rhs: &str, max_distance: usize) -> Option<usize> {

crates/checks/version-age/src/lib.rs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,20 @@ async fn run(
5252
return None;
5353
}
5454

55-
Some(CheckFinding {
56-
severity: Severity::High,
57-
reason: format!(
58-
"{package_name}@{} was published {} day(s) ago (< {min_version_age_days} days)",
59-
version.version, age_days
60-
),
61-
})
55+
Some(
56+
CheckFinding::new(
57+
Severity::High,
58+
format!(
59+
"{package_name}@{} was published {} day(s) ago (< {min_version_age_days} days)",
60+
version.version, age_days
61+
),
62+
"too_new",
63+
)
64+
.with_fact("package_name", package_name)
65+
.with_fact("resolved_version", version.version.as_str())
66+
.with_fact("age_days", age_days)
67+
.with_fact("min_age_days", min_version_age_days),
68+
)
6269
}
6370

6471
#[cfg(test)]

0 commit comments

Comments
 (0)