use super::Error;
use super::Registry;
use serde::{Deserialize, Serialize};
const GRAPHQL_URL: &str = "https://api.github.com/graphql";
const GRAPHQL_ERROR_STATUS: u16 = 200;
const ADVISORY_QUERY: &str = "\
query {
securityVulnerabilities(ecosystem: ACTIONS, first: 100) {
totalCount
nodes {
vulnerableVersionRange
firstPatchedVersion { identifier }
package { name }
advisory { ghsaId summary severity permalink withdrawnAt }
}
}
}";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Severity {
Low,
Moderate,
High,
Critical,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Advisory {
pub package: String,
pub ghsa_id: String,
pub summary: String,
pub severity: Severity,
pub permalink: String,
pub vulnerable_range: String,
pub first_patched: Option<String>,
}
pub trait AdvisoryQuery {
fn all_actions_advisories(&self) -> Result<Vec<Advisory>, Error>;
}
#[derive(Serialize)]
struct Request<'req> {
query: &'req str,
}
#[derive(Deserialize)]
struct Response {
data: Option<ResponseData>,
#[serde(default)]
errors: Vec<GraphQlError>,
}
#[derive(Deserialize)]
struct GraphQlError {
message: String,
}
#[derive(Deserialize)]
struct ResponseData {
#[serde(rename = "securityVulnerabilities")]
security_vulnerabilities: Vulnerabilities,
}
#[derive(Deserialize)]
struct Vulnerabilities {
#[serde(rename = "totalCount")]
total_count: usize,
nodes: Vec<VulnerabilityNode>,
}
#[derive(Deserialize)]
struct VulnerabilityNode {
#[serde(rename = "vulnerableVersionRange")]
vulnerable_version_range: String,
#[serde(rename = "firstPatchedVersion")]
first_patched_version: Option<PatchedVersion>,
package: PackageNode,
advisory: AdvisoryNode,
}
#[derive(Deserialize)]
struct PackageNode {
name: String,
}
#[derive(Deserialize)]
struct PatchedVersion {
identifier: String,
}
#[derive(Deserialize)]
struct AdvisoryNode {
#[serde(rename = "ghsaId")]
ghsa_id: String,
summary: String,
severity: Severity,
permalink: String,
#[serde(rename = "withdrawnAt")]
withdrawn_at: Option<String>,
}
pub struct GraphQlAdvisories {
registry: Registry,
}
impl GraphQlAdvisories {
#[must_use]
pub const fn new(registry: Registry) -> Self {
Self { registry }
}
fn interpret(response: Response) -> Result<Vec<Advisory>, Error> {
if let Some(first) = response.errors.first() {
return Err(Error::ApiError {
status: GRAPHQL_ERROR_STATUS,
url: format!("{GRAPHQL_URL} ({})", first.message),
});
}
let Some(data) = response.data else {
return Err(Error::ApiError {
status: GRAPHQL_ERROR_STATUS,
url: format!("{GRAPHQL_URL} (response contained no data)"),
});
};
let connection = data.security_vulnerabilities;
if connection.total_count > connection.nodes.len() {
return Err(Error::ApiError {
status: GRAPHQL_ERROR_STATUS,
url: format!(
"{GRAPHQL_URL} (returned {} of {} advisories; the set no longer fits one page)",
connection.nodes.len(),
connection.total_count
),
});
}
Ok(connection
.nodes
.into_iter()
.filter(|node| node.advisory.withdrawn_at.is_none())
.map(|node| Advisory {
package: node.package.name,
ghsa_id: node.advisory.ghsa_id,
summary: node.advisory.summary,
severity: node.advisory.severity,
permalink: node.advisory.permalink,
vulnerable_range: node.vulnerable_version_range,
first_patched: node.first_patched_version.map(|v| v.identifier),
})
.collect())
}
}
impl AdvisoryQuery for GraphQlAdvisories {
fn all_actions_advisories(&self) -> Result<Vec<Advisory>, Error> {
let body = Request {
query: ADVISORY_QUERY,
};
let request = self.registry.authenticated_post(GRAPHQL_URL).json(&body);
let response = request.send().map_err(|source| Error::Request {
operation: "security advisories",
url: GRAPHQL_URL.to_owned(),
source,
})?;
if !response.status().is_success() {
return Err(Registry::check_status(&response, GRAPHQL_URL));
}
let decoded: Response = response.json().map_err(|source| Error::ParseResponse {
url: GRAPHQL_URL.to_owned(),
source,
})?;
Self::interpret(decoded)
}
}
#[cfg(test)]
pub(crate) mod fake {
use super::{Advisory, AdvisoryQuery, Error, GRAPHQL_URL};
use std::cell::Cell;
pub struct FakeAdvisories {
result: Result<Vec<Advisory>, ()>,
calls: Cell<usize>,
}
impl FakeAdvisories {
#[must_use]
pub fn new(advisories: Vec<Advisory>) -> Self {
Self {
result: Ok(advisories),
calls: Cell::new(0),
}
}
#[must_use]
pub fn failing() -> Self {
Self {
result: Err(()),
calls: Cell::new(0),
}
}
pub fn calls(&self) -> usize {
self.calls.get()
}
}
impl AdvisoryQuery for FakeAdvisories {
fn all_actions_advisories(&self) -> Result<Vec<Advisory>, Error> {
self.calls.set(self.calls.get().saturating_add(1));
self.result.clone().map_err(|()| Error::Unauthorized {
url: GRAPHQL_URL.to_owned(),
})
}
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::indexing_slicing,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
mod tests {
use super::fake::FakeAdvisories;
use super::{
ADVISORY_QUERY, Advisory, AdvisoryQuery as _, GraphQlAdvisories, Request, Response,
Severity,
};
#[test]
fn request_body_asks_for_the_whole_ecosystem() {
let body = Request {
query: ADVISORY_QUERY,
};
let json: serde_json::Value = serde_json::to_value(&body).unwrap();
let query = json["query"].as_str().unwrap();
assert!(query.contains("ecosystem: ACTIONS"));
assert!(json.get("variables").is_none());
assert!(!query.contains("$package"));
assert!(query.contains("totalCount"));
assert!(query.contains("package { name }"));
assert!(query.contains("withdrawnAt"));
}
#[test]
fn withdrawn_advisories_are_filtered_after_completeness_validation() {
let node = |id, withdrawn_at| {
serde_json::json!({
"vulnerableVersionRange": "< 0.1.22",
"firstPatchedVersion": null,
"package": { "name": "google-github-actions/run-gemini-cli" },
"advisory": {
"ghsaId": id,
"summary": "test advisory",
"severity": "HIGH",
"permalink": "https://example.invalid",
"withdrawnAt": withdrawn_at
}
})
};
let withdrawn = node("GHSA-jj69-4grx-fqj5", Some("2026-01-01T00:00:00Z"));
let active = node("GHSA-active", None);
for (nodes, total, expected) in [
(
vec![withdrawn.clone(), active.clone()],
2,
Some(vec!["GHSA-active"]),
),
(vec![withdrawn.clone()], 1, Some(vec![])),
(vec![withdrawn], 2, None),
(vec![active], 2, None),
] {
let decoded = serde_json::from_value(serde_json::json!({
"data": { "securityVulnerabilities": { "totalCount": total, "nodes": nodes } }
}))
.unwrap();
let result = GraphQlAdvisories::interpret(decoded);
if let Some(ids) = expected {
let advisories = result.unwrap();
assert_eq!(
advisories
.iter()
.map(|advisory| advisory.ghsa_id.as_str())
.collect::<Vec<_>>(),
ids
);
} else {
assert!(result.unwrap_err().to_string().contains("returned 1 of 2"));
}
}
}
#[test]
fn parses_a_vulnerability_payload() {
let raw = r#"{
"data": {
"securityVulnerabilities": {
"totalCount": 1,
"nodes": [{
"vulnerableVersionRange": "< 45.0.7",
"firstPatchedVersion": { "identifier": "45.0.7" },
"package": { "name": "tj-actions/changed-files" },
"advisory": {
"ghsaId": "GHSA-mrrh-fwg8-r2c3",
"summary": "tj-actions/changed-files leaks secrets",
"severity": "HIGH",
"permalink": "https://github.com/advisories/GHSA-mrrh-fwg8-r2c3"
}
}]
}
}
}"#;
let decoded: Response = serde_json::from_str(raw).unwrap();
let advisories = GraphQlAdvisories::interpret(decoded).unwrap();
assert_eq!(advisories.len(), 1);
assert_eq!(advisories[0].package, "tj-actions/changed-files");
assert_eq!(advisories[0].ghsa_id, "GHSA-mrrh-fwg8-r2c3");
assert_eq!(advisories[0].severity, Severity::High);
assert_eq!(advisories[0].vulnerable_range, "< 45.0.7");
assert_eq!(advisories[0].first_patched.as_deref(), Some("45.0.7"));
}
#[test]
fn parses_an_empty_result_as_no_advisories() {
let raw = r#"{"data": {"securityVulnerabilities": {"totalCount": 0, "nodes": []}}}"#;
let decoded: Response = serde_json::from_str(raw).unwrap();
assert!(GraphQlAdvisories::interpret(decoded).unwrap().is_empty());
}
#[test]
fn advisory_without_patch_parses() {
let raw = r#"{
"data": {
"securityVulnerabilities": {
"totalCount": 1,
"nodes": [{
"vulnerableVersionRange": ">= 0",
"firstPatchedVersion": null,
"package": { "name": "some/action" },
"advisory": {
"ghsaId": "GHSA-xxxx-yyyy-zzzz",
"summary": "no fix available",
"severity": "CRITICAL",
"permalink": "https://example.invalid"
}
}]
}
}
}"#;
let decoded: Response = serde_json::from_str(raw).unwrap();
let advisories = GraphQlAdvisories::interpret(decoded).unwrap();
assert_eq!(advisories[0].first_patched, None);
assert_eq!(advisories[0].severity, Severity::Critical);
}
#[test]
fn query_level_errors_are_not_a_clean_result() {
let raw = r#"{"data": null, "errors": [{"message": "Bad credentials"}]}"#;
let decoded: Response = serde_json::from_str(raw).unwrap();
let err = GraphQlAdvisories::interpret(decoded).unwrap_err();
assert!(
format!("{err}").contains("Bad credentials"),
"query errors must not read as clean, and must name the cause: {err}"
);
}
#[test]
fn missing_data_is_not_a_clean_result() {
let raw = r"{}";
let decoded: Response = serde_json::from_str(raw).unwrap();
GraphQlAdvisories::interpret(decoded).unwrap_err();
}
#[test]
fn fake_satisfies_the_trait_without_network() {
let advisory = Advisory {
package: "actions/checkout".to_owned(),
ghsa_id: "GHSA-test".to_owned(),
summary: "test".to_owned(),
severity: Severity::High,
permalink: "https://example.invalid".to_owned(),
vulnerable_range: "< 2.0.0".to_owned(),
first_patched: Some("2.0.0".to_owned()),
};
let fake = FakeAdvisories::new(vec![advisory.clone()]);
let got = fake.all_actions_advisories().unwrap();
assert_eq!(got, vec![advisory]);
assert_eq!(fake.calls(), 1);
}
#[test]
fn fake_can_report_a_failed_lookup() {
let fake = FakeAdvisories::failing();
fake.all_actions_advisories().unwrap_err();
}
}