1use super::EcrClient;
3use crate::pagination::{cap_results, Pagination, StopReason};
4use anyhow::{anyhow, ensure, Result};
5use aws_sdk_ecr::types::{EnhancedImageScanFinding, ImageIdentifier, ImageScanFinding};
6use std::collections::BTreeMap;
7use tokio::time::{timeout_at, Duration, Instant};
8
9pub const FINDING_LIMIT: usize = 1_000;
10const PAGE_LIMIT: usize = 100;
11const TIMEOUT: Duration = Duration::from_secs(30);
12
13#[derive(Debug, Default)]
14pub struct ScanReport {
15 pub status: String,
16 pub description: String,
17 pub completed_at: String,
18 pub source_updated_at: String,
19 pub severity_counts: BTreeMap<String, i32>,
21 pub findings: Vec<Finding>,
22 pub has_findings_payload: bool,
23 pub warning: Option<String>,
24}
25
26#[derive(Debug)]
27pub struct Finding {
28 pub severity: String,
29 pub name: String,
30 pub description: String,
31 pub details: Vec<(String, String)>,
32}
33
34impl ScanReport {
35 pub fn complete(&self) -> bool {
36 self.has_findings_payload
37 && matches!(self.status.as_str(), "COMPLETE" | "ACTIVE")
38 && self.warning.is_none()
39 }
40}
41
42impl EcrClient {
43 pub async fn image_scan_findings(&self, repository: &str, digest: &str) -> Result<ScanReport> {
44 ensure!(!repository.is_empty(), "Missing ECR repository name");
45 ensure!(
46 !digest.is_empty(),
47 "Missing image digest; refresh the image list"
48 );
49 let client = self.config.ecr_client();
50 let mut report = ScanReport::default();
51 let mut token = None;
52 let mut pagination = Pagination::new(PAGE_LIMIT);
53 let deadline = Instant::now() + TIMEOUT;
54 for page in 0..PAGE_LIMIT {
55 let result = timeout_at(
56 deadline,
57 client
58 .describe_image_scan_findings()
59 .repository_name(repository)
60 .image_id(ImageIdentifier::builder().image_digest(digest).build())
61 .max_results(100)
62 .set_next_token(token)
63 .send(),
64 )
65 .await;
66 let response = match result {
67 Ok(Ok(response)) => response,
68 result => {
69 let error = match result {
70 Ok(Err(error)) => {
71 use aws_sdk_ecr::error::ProvideErrorMetadata;
72 if let Some(service) = error.as_service_error() {
73 format!(
74 "{}: {}",
75 service.code().unwrap_or("ECR error"),
76 service.message().unwrap_or("No details reported")
77 )
78 } else {
79 format!("{:#}", anyhow::Error::new(error))
80 }
81 }
82 _ => "Scan findings request timed out after 30 seconds".into(),
83 };
84 if page == 0 {
85 return Err(anyhow!("Scan findings unavailable: {error}"));
86 }
87 report.warning = Some(format!("Partial results: {error}"));
88 break;
89 }
90 };
91 if let Some(status) = response.image_scan_status {
92 report.status = status.status.map(|s| s.as_str().into()).unwrap_or_default();
93 report.description = status.description.unwrap_or_default();
94 }
95 if let Some(findings) = response.image_scan_findings {
96 report.has_findings_payload = true;
97 if let Some(date) = findings.image_scan_completed_at {
98 report.completed_at = timestamp(date);
99 }
100 if let Some(date) = findings.vulnerability_source_updated_at {
101 report.source_updated_at = timestamp(date);
102 }
103 if let Some(counts) = findings.finding_severity_counts {
104 report.severity_counts = counts
105 .into_iter()
106 .map(|(severity, count)| (severity.as_str().into(), count))
107 .collect();
108 }
109 let rows = findings
110 .findings
111 .unwrap_or_default()
112 .into_iter()
113 .map(basic)
114 .chain(
115 findings
116 .enhanced_findings
117 .unwrap_or_default()
118 .into_iter()
119 .map(enhanced),
120 );
121 let remaining = FINDING_LIMIT + 1 - report.findings.len();
123 report.findings.extend(rows.take(remaining));
124 } else {
125 report.warning = Some("Scan findings payload unavailable; refresh to retry".into());
126 break;
127 }
128 if cap_results(
129 &mut report.findings,
130 FINDING_LIMIT,
131 response.next_token.as_deref(),
132 ) {
133 report.warning = Some(format!(
134 "Partial results: limited to {FINDING_LIMIT} findings"
135 ));
136 break;
137 }
138 match pagination.advance(response.next_token) {
139 Ok(Some(next)) => token = Some(next),
140 Ok(None) => break,
141 Err(reason) => {
142 report.warning = Some(match reason {
143 StopReason::RepeatedToken => {
144 "Partial results: repeated pagination token".into()
145 }
146 StopReason::ResultLimit => {
147 format!("Partial results: limited to {FINDING_LIMIT} findings")
148 }
149 StopReason::PageLimit => {
150 format!("Partial results: stopped after {PAGE_LIMIT} pages")
151 }
152 });
153 break;
154 }
155 }
156 }
157 report.findings.sort_by(|a, b| {
158 severity_rank(&a.severity)
159 .cmp(&severity_rank(&b.severity))
160 .then_with(|| a.name.cmp(&b.name))
161 });
162 Ok(report)
163 }
164}
165
166fn timestamp(date: aws_smithy_types::DateTime) -> String {
167 date.fmt(aws_smithy_types::date_time::Format::DateTime)
168 .unwrap_or_default()
169}
170
171fn severity_rank(severity: &str) -> usize {
172 [
173 "CRITICAL",
174 "HIGH",
175 "MEDIUM",
176 "LOW",
177 "INFORMATIONAL",
178 "UNDEFINED",
179 ]
180 .iter()
181 .position(|s| *s == severity)
182 .unwrap_or(6)
183}
184
185fn basic(finding: ImageScanFinding) -> Finding {
186 let mut details = vec![("Scanner".into(), "Basic".into())];
187 if let Some(uri) = finding.uri {
188 details.push(("Reference".into(), uri));
189 }
190 for attribute in finding.attributes.unwrap_or_default() {
191 details.push((attribute.key, attribute.value.unwrap_or_else(|| "-".into())));
192 }
193 Finding {
194 severity: finding
195 .severity
196 .map(|s| s.as_str().into())
197 .unwrap_or_else(|| "UNKNOWN".into()),
198 name: finding.name.unwrap_or_else(|| "Unnamed finding".into()),
199 description: finding.description.unwrap_or_default(),
200 details,
201 }
202}
203
204fn enhanced(finding: EnhancedImageScanFinding) -> Finding {
205 let mut details = vec![("Scanner".into(), "Enhanced (Inspector)".into())];
206 for (label, value) in [
207 ("Status", finding.status),
208 ("Fix available", finding.fix_available),
209 ("Exploit available", finding.exploit_available),
210 ("Finding ARN", finding.finding_arn),
211 ] {
212 if let Some(value) = value {
213 details.push((label.into(), value));
214 }
215 }
216 let mut name = finding.title.unwrap_or_else(|| "Unnamed finding".into());
217 if let Some(vulnerability) = finding.package_vulnerability_details {
218 if let Some(id) = vulnerability.vulnerability_id {
219 name = id;
220 }
221 if let Some(url) = vulnerability.source_url {
222 details.push(("Reference".into(), url));
223 }
224 for package in vulnerability.vulnerable_packages.unwrap_or_default() {
225 details.push(("Package".into(), package.name.unwrap_or_else(|| "-".into())));
226 for (label, value) in [
227 ("Installed version", package.version),
228 ("Release", package.release),
229 ("Architecture", package.arch),
230 ("Package manager", package.package_manager),
231 ("Path", package.file_path),
232 ] {
233 if let Some(value) = value {
234 details.push((label.into(), value));
235 }
236 }
237 if let Some(epoch) = package.epoch {
238 details.push(("Epoch".into(), epoch.to_string()));
239 }
240 details.push((
241 "Fixed in".into(),
242 package
243 .fixed_in_version
244 .unwrap_or_else(|| "Not reported".into()),
245 ));
246 }
247 }
248 Finding {
249 severity: finding.severity.unwrap_or_else(|| "UNKNOWN".into()),
250 name,
251 description: finding.description.unwrap_or_default(),
252 details,
253 }
254}
255
256#[cfg(test)]
257mod tests;