1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
//! Whether a known vulnerability can be remediated with `gx upgrade`.
//!
//! `gx upgrade` moves an action only *within* its manifest specifier, so a patched version
//! is reachable only when the specifier admits it. Suggesting a command that cannot reach
//! the fix is worse than suggesting nothing — the user runs it mid-incident and nothing
//! changes — so this module errs conservative.
use super::action::identity::Version;
use super::action::specifier::{Specifier, parse_semver};
/// What a user can do about an action with a known vulnerability.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Remediation {
/// `gx upgrade <action>` reaches the fix.
Upgradable {
/// The advisory's first patched version, `v`-prefixed.
fixed: Version,
},
/// Nothing to upgrade to; the user must migrate away from the action.
NoFixAvailable,
/// A fix exists but the manifest entry must change to reach it.
///
/// The obstacle is not always range width. Only a [`Specifier::Range`] needs widening;
/// a branch, a SHA, and an exact pin like `46.0.0` have no range at all, so a caller
/// must read the specifier before naming the obstacle.
OutOfRange {
/// The advisory's first patched version, `v`-prefixed.
fixed: Version,
},
}
impl Remediation {
/// Classify how an advisory's fix relates to what the manifest allows.
///
/// `first_patched` is the advisory's `firstPatchedVersion`, absent when there is no
/// known fix, and `v`-prefixed or not. Whether the action is vulnerable at all is the
/// caller's question; this only answers what to do about it.
#[must_use]
pub fn classify(specifier: &Specifier, first_patched: Option<&str>) -> Self {
// An unreadable identifier is treated as an absent one. Reporting it as
// `OutOfRange` would promise that a wider specifier reaches a fix.
let Some(patched) = first_patched.and_then(parse_semver) else {
return Self::NoFixAvailable;
};
// From the parsed version, not the raw identifier, so `V46.0.1` and `2.37` reach
// the user as `v46.0.1` and `v2.37.0`.
let fixed = Version::normalized(&patched.to_string());
// `matches` reports a rangeless pin as unmatched, which is the verdict wanted here
// — unlike `matches_version`, which exempts such pins because it asks whether a pin
// is permitted, not whether an upgrade reaches it.
if specifier.matches(&patched) {
return Self::Upgradable { fixed };
}
// Semver admits a prerelease only into a range whose own bound carries one, so a
// rejected prerelease is beyond every range the user could write — not merely
// beyond this one.
if patched.pre.is_empty() {
Self::OutOfRange { fixed }
} else {
Self::NoFixAvailable
}
}
}
#[cfg(test)]
mod tests {
use super::{Remediation, Specifier, Version};
/// Real case: tj-actions/changed-files, GHSA-mrrh-fwg8-r2c3.
#[test]
fn caret_major_reaches_patch_in_same_major() {
assert_eq!(
Remediation::classify(&Specifier::parse("^46"), Some("46.0.1")),
Remediation::Upgradable {
fixed: Version::from("v46.0.1")
}
);
}
/// Real case: shivammathur/setup-php.
#[test]
fn tilde_minor_reaches_patch_on_same_minor_line() {
assert_eq!(
Remediation::classify(&Specifier::parse("~2.37"), Some("2.37.1")),
Remediation::Upgradable {
fixed: Version::from("v2.37.1")
}
);
}
/// Real case: github/codeql-action.
#[test]
fn caret_major_does_not_reach_next_major() {
assert_eq!(
Remediation::classify(&Specifier::parse("^2"), Some("3.0.0")),
Remediation::OutOfRange {
fixed: Version::from("v3.0.0")
}
);
}
/// Real case: aquasecurity/trivy-action. A caret on `0.x` is locked to that
/// minor, so this looks like a reachable minor bump but is not.
#[test]
fn zero_major_caret_is_patch_locked() {
assert_eq!(
Remediation::classify(&Specifier::parse("^0.34"), Some("0.35.0")),
Remediation::OutOfRange {
fixed: Version::from("v0.35.0")
}
);
}
/// Real case: reviewdog/action-setup. 5 of 63 ACTIONS advisories name no
/// patched version, so this arm is not hypothetical.
#[test]
fn absent_patched_version_has_no_fix() {
assert_eq!(
Remediation::classify(&Specifier::parse("^1"), None),
Remediation::NoFixAvailable
);
}
/// Advisories usually omit the `v` that gx tags carry.
#[test]
fn v_prefix_does_not_change_the_verdict() {
let bare = Remediation::classify(&Specifier::parse("^46"), Some("46.0.1"));
let prefixed = Remediation::classify(&Specifier::parse("^46"), Some("v46.0.1"));
assert_eq!(bare, prefixed);
assert_eq!(
prefixed,
Remediation::Upgradable {
fixed: Version::from("v46.0.1")
}
);
}
#[test]
fn v_prefix_does_not_change_an_out_of_range_verdict() {
assert_eq!(
Remediation::classify(&Specifier::parse("^2"), Some("v3.0.0")),
Remediation::OutOfRange {
fixed: Version::from("v3.0.0")
}
);
}
#[test]
fn branch_specifier_is_never_upgradable() {
assert_eq!(
Remediation::classify(&Specifier::parse("main"), Some("46.0.1")),
Remediation::OutOfRange {
fixed: Version::from("v46.0.1")
}
);
}
#[test]
fn sha_specifier_is_never_upgradable() {
let sha = "a".repeat(40);
assert_eq!(
Remediation::classify(&Specifier::parse(&sha), Some("46.0.1")),
Remediation::OutOfRange {
fixed: Version::from("v46.0.1")
}
);
}
/// A manifest value is not validated into range form, so an exact pin becomes
/// a `Ref`. The fix is one patch away and still unreachable — this is why
/// `OutOfRange` must not be rendered as "requires a major bump".
#[test]
fn exact_pin_has_no_range_to_search() {
assert_eq!(
Remediation::classify(&Specifier::parse("46.0.0"), Some("46.0.1")),
Remediation::OutOfRange {
fixed: Version::from("v46.0.1")
}
);
}
/// Not `OutOfRange`: widening a specifier cannot reach a version that does
/// not exist.
#[test]
fn unparseable_patched_version_has_no_fix() {
assert_eq!(
Remediation::classify(&Specifier::parse("^2"), Some("unreleased")),
Remediation::NoFixAvailable
);
}
/// `NoFixAvailable` rather than `OutOfRange`: no ordinary range admits a
/// prerelease, so no specifier the user could write reaches this fix.
#[test]
fn prerelease_patched_version_has_no_reachable_fix() {
assert_eq!(
Remediation::classify(&Specifier::parse("^2"), Some("2.1.0-beta.1")),
Remediation::NoFixAvailable
);
}
/// A range carrying a prerelease does admit one, and gx derives such ranges
/// (`Version::specifier` maps `v3.0.0-beta.2` to `~3.0.0-beta.2`), so
/// filtering prereleases up front hid reachable fixes.
#[test]
fn prerelease_range_reaches_a_prerelease_fix() {
assert_eq!(
Remediation::classify(&Specifier::parse("^2.0.0-beta.1"), Some("2.0.0-beta.2")),
Remediation::Upgradable {
fixed: Version::from("v2.0.0-beta.2")
}
);
assert_eq!(
Remediation::classify(&Specifier::parse("~2.1.0-beta.1"), Some("2.1.0-beta.2")),
Remediation::Upgradable {
fixed: Version::from("v2.1.0-beta.2")
}
);
}
/// A prerelease range does not make every prerelease reachable.
#[test]
fn prerelease_range_does_not_reach_a_later_major_prerelease() {
assert_eq!(
Remediation::classify(&Specifier::parse("^2.0.0-beta.1"), Some("3.0.0-beta.1")),
Remediation::NoFixAvailable
);
}
#[test]
fn empty_patched_version_has_no_fix() {
assert_eq!(
Remediation::classify(&Specifier::parse("^2"), Some("")),
Remediation::NoFixAvailable
);
}
/// `fixed` carries gx's lowercase `v`, not the advisory's casing.
#[test]
fn uppercase_v_prefix_is_canonicalized() {
assert_eq!(
Remediation::classify(&Specifier::parse("^46"), Some("V46.0.1")),
Remediation::Upgradable {
fixed: Version::from("v46.0.1")
}
);
}
/// Reporting "fixed in v2" would read as a range; the user needs a version.
#[test]
fn imprecise_patched_version_is_padded_to_a_concrete_version() {
assert_eq!(
Remediation::classify(&Specifier::parse("~2.37"), Some("2.37")),
Remediation::Upgradable {
fixed: Version::from("v2.37.0")
}
);
assert_eq!(
Remediation::classify(&Specifier::parse("^2"), Some("2")),
Remediation::Upgradable {
fixed: Version::from("v2.0.0")
}
);
}
}