Skip to main content

rusticity_core/ecr/
scan.rs

1//! Read existing private ECR scan results by immutable image digest. Never starts a scan.
2use 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    /// AWS's summary, not a count derived from a potentially truncated list.
20    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                // Keep one extra to distinguish exactly-at-limit from truncated results.
122                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;