diff --git a/src/GitHubVulnerabilities2Db/Collector/AdvisoryQueryBuilder.cs b/src/GitHubVulnerabilities2Db/Collector/AdvisoryQueryBuilder.cs index d3844e1e86..1f7c2cb7b0 100644 --- a/src/GitHubVulnerabilities2Db/Collector/AdvisoryQueryBuilder.cs +++ b/src/GitHubVulnerabilities2Db/Collector/AdvisoryQueryBuilder.cs @@ -4,11 +4,19 @@ using System; using System.Linq; using GitHubVulnerabilities2Db.GraphQL; +using Newtonsoft.Json; namespace GitHubVulnerabilities2Db.Collector { public class AdvisoryQueryBuilder : IAdvisoryQueryBuilder { + private const string SecurityAdvisoryFields = @"databaseId + ghsaId + permalink + severity + withdrawnAt + updatedAt"; + public int GetMaximumResultsPerRequest() => 100; public string CreateSecurityAdvisoriesQuery(DateTimeOffset? updatedSince = null, string afterCursor = null) @@ -20,27 +28,21 @@ public string CreateSecurityAdvisoriesQuery(DateTimeOffset? updatedSince = null, edges { cursor node { - databaseId - permalink - severity - withdrawnAt - updatedAt + " + SecurityAdvisoryFields + @" " + CreateVulnerabilitiesConnectionQuery() + @" } } } }"; + /// + /// Source: https://docs.github.com/en/enterprise-cloud@latest/graphql/reference/queries#securityadvisory + /// public string CreateSecurityAdvisoryQuery(SecurityAdvisory advisory) => @" { - securityAdvisory(databaseId: " + advisory.DatabaseId + @") { - severity - updatedAt - identifiers { - type - value - } + securityAdvisory(ghsaId: " + JsonConvert.SerializeObject(advisory.GhsaId) + @") { + " + SecurityAdvisoryFields + @" " + CreateVulnerabilitiesConnectionQuery(advisory.Vulnerabilities?.Edges?.Last()?.Cursor) + @" } }"; diff --git a/src/GitHubVulnerabilities2Db/Collector/AdvisoryQueryService.cs b/src/GitHubVulnerabilities2Db/Collector/AdvisoryQueryService.cs index 4183a0e6d4..303ad414da 100644 --- a/src/GitHubVulnerabilities2Db/Collector/AdvisoryQueryService.cs +++ b/src/GitHubVulnerabilities2Db/Collector/AdvisoryQueryService.cs @@ -53,7 +53,7 @@ private async Task FetchAllVulnerabilitiesAsync(SecurityAdviso var lastVulnerabilitiesFetchedCount = advisory.Vulnerabilities?.Edges?.Count() ?? 0; while (lastVulnerabilitiesFetchedCount == _queryBuilder.GetMaximumResultsPerRequest()) { - _logger.LogInformation("Fetching more vulnerabilities for advisory with database key {GitHubDatabaseKey}", advisory.DatabaseId); + _logger.LogInformation("Fetching more vulnerabilities for advisory with database key {GitHubDatabaseKey} / GHSA ID {GhsaId}", advisory.DatabaseId, advisory.GhsaId); var queryForAdditionalVulnerabilities = _queryBuilder.CreateSecurityAdvisoryQuery(advisory); var responseForAdditionalVulnerabilities = await _queryService.QueryAsync(queryForAdditionalVulnerabilities, token); var advisoryWithAdditionalVulnerabilities = responseForAdditionalVulnerabilities.Data.SecurityAdvisory; diff --git a/src/GitHubVulnerabilities2Db/GraphQL/QueryResponse.cs b/src/GitHubVulnerabilities2Db/GraphQL/QueryResponse.cs index 7967d515b3..f74e1c8472 100644 --- a/src/GitHubVulnerabilities2Db/GraphQL/QueryResponse.cs +++ b/src/GitHubVulnerabilities2Db/GraphQL/QueryResponse.cs @@ -11,6 +11,16 @@ namespace GitHubVulnerabilities2Db.GraphQL public class QueryResponse { public QueryResponseData Data { get; set; } + public List Errors { get; set; } + } + + /// + /// The optional error details returned by the GraphQL endpoint. + /// See: https://www.apollographql.com/docs/react/data/error-handling/#graphql-errors + /// + public class QueryError + { + public string Message { get; set; } } /// diff --git a/src/GitHubVulnerabilities2Db/GraphQL/QueryService.cs b/src/GitHubVulnerabilities2Db/GraphQL/QueryService.cs index 4f56c49693..6ac52834a5 100644 --- a/src/GitHubVulnerabilities2Db/GraphQL/QueryService.cs +++ b/src/GitHubVulnerabilities2Db/GraphQL/QueryService.cs @@ -39,7 +39,16 @@ public async Task QueryAsync(string query, CancellationToken toke }; var response = await MakeWebRequestAsync(queryJObject.ToString(), token); - return JsonConvert.DeserializeObject(response); + var queryResponse = JsonConvert.DeserializeObject(response); + + if (queryResponse.Errors != null && queryResponse.Errors.Count > 0) + { + throw new InvalidOperationException( + "The GitHub GraphQL response returned errors in the response JSON. " + + $"Response body:{Environment.NewLine}{response}"); + } + + return queryResponse; } private async Task MakeWebRequestAsync(string query, CancellationToken token) @@ -47,7 +56,15 @@ private async Task MakeWebRequestAsync(string query, CancellationToken t using (var request = CreateRequest(query)) using (var response = await _client.SendAsync(request, token)) { - return await response.Content.ReadAsStringAsync(); + var responseBody = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"The GitHub GraphQL response returned status code {(int)response.StatusCode} {response.ReasonPhrase}. " + + $"Response body:{Environment.NewLine}{responseBody}"); + } + + return responseBody; } } diff --git a/src/GitHubVulnerabilities2Db/GraphQL/SecurityAdvisory.cs b/src/GitHubVulnerabilities2Db/GraphQL/SecurityAdvisory.cs index 6a3ce91874..8abba84abe 100644 --- a/src/GitHubVulnerabilities2Db/GraphQL/SecurityAdvisory.cs +++ b/src/GitHubVulnerabilities2Db/GraphQL/SecurityAdvisory.cs @@ -11,6 +11,7 @@ namespace GitHubVulnerabilities2Db.GraphQL public class SecurityAdvisory : INode { public int DatabaseId { get; set; } + public string GhsaId { get; set; } public string Permalink { get; set; } public string Severity { get; set; } public DateTimeOffset UpdatedAt { get; set; } diff --git a/src/GitHubVulnerabilities2Db/Job.cs b/src/GitHubVulnerabilities2Db/Job.cs index c5fe62cda5..2b953cd3e1 100644 --- a/src/GitHubVulnerabilities2Db/Job.cs +++ b/src/GitHubVulnerabilities2Db/Job.cs @@ -34,7 +34,8 @@ public class Job : JsonConfigurationJob, IDisposable public override async Task Run() { var collector = _serviceProvider.GetRequiredService(); - while (await collector.ProcessAsync(CancellationToken.None)) ; + while (await collector.ProcessAsync(CancellationToken.None)); + } protected override void ConfigureJobServices(IServiceCollection services, IConfigurationRoot configurationRoot) diff --git a/tests/GitHubVulnerabilities2Db.Facts/QueryServiceFacts.cs b/tests/GitHubVulnerabilities2Db.Facts/QueryServiceFacts.cs index b35cecbd8d..b31fc39546 100644 --- a/tests/GitHubVulnerabilities2Db.Facts/QueryServiceFacts.cs +++ b/tests/GitHubVulnerabilities2Db.Facts/QueryServiceFacts.cs @@ -2,6 +2,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text; @@ -56,6 +58,56 @@ public async Task Success() Assert.True(_handler.WasCalled); } + [Fact] + public async Task ErrorStatusCodeIsRejected() + { + // Arrange + var query = "someString"; + _handler.ExpectedQueryContent = (new JObject { ["query"] = query }).ToString(); + + _handler.ExpectedEndpoint = new Uri("https://graphQL.net"); + _configuration.GitHubGraphQLQueryEndpoint = _handler.ExpectedEndpoint; + + _handler.ExpectedApiKey = "patpatpat"; + _configuration.GitHubPersonalAccessToken = _handler.ExpectedApiKey; + + var response = new QueryResponse(); + _handler.ResponseMessage = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(JsonConvert.SerializeObject(response)) + }; + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _service.QueryAsync(query, new CancellationToken())); + Assert.True(_handler.WasCalled); + Assert.Equal("The GitHub GraphQL response returned status code 400 Bad Request. Response body:\r\n{\"Data\":null,\"Errors\":null}", ex.Message); + } + + [Fact] + public async Task ErrorResponseJsonIsRejected() + { + // Arrange + var query = "someString"; + _handler.ExpectedQueryContent = (new JObject { ["query"] = query }).ToString(); + + _handler.ExpectedEndpoint = new Uri("https://graphQL.net"); + _configuration.GitHubGraphQLQueryEndpoint = _handler.ExpectedEndpoint; + + _handler.ExpectedApiKey = "patpatpat"; + _configuration.GitHubPersonalAccessToken = _handler.ExpectedApiKey; + + var response = new QueryResponse { Errors = new List { new QueryError { Message = "Query = not great" } } }; + _handler.ResponseMessage = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonConvert.SerializeObject(response)) + }; + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _service.QueryAsync(query, new CancellationToken())); + Assert.True(_handler.WasCalled); + Assert.Equal("The GitHub GraphQL response returned errors in the response JSON. Response body:\r\n{\"Data\":null,\"Errors\":[{\"Message\":\"Query = not great\"}]}", ex.Message); + } + private class QueryServiceHttpClientHandler : HttpClientHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)