|
| 1 | +// Copyright (c) .NET Foundation. All rights reserved. |
| 2 | +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. |
| 3 | + |
| 4 | +using System; |
| 5 | +using System.Collections.Generic; |
| 6 | +using System.Data.Entity; |
| 7 | +using System.Data.SqlClient; |
| 8 | +using System.Linq; |
| 9 | +using System.Net.Http; |
| 10 | +using System.Threading; |
| 11 | +using System.Threading.Tasks; |
| 12 | +using GitHubVulnerabilities2Db.Collector; |
| 13 | +using GitHubVulnerabilities2Db.Configuration; |
| 14 | +using GitHubVulnerabilities2Db.GraphQL; |
| 15 | +using GitHubVulnerabilities2Db.Ingest; |
| 16 | +using Microsoft.Extensions.CommandLineUtils; |
| 17 | +using Microsoft.Extensions.Logging.Abstractions; |
| 18 | +using NuGet.Services.Entities; |
| 19 | +using NuGet.Versioning; |
| 20 | +using NuGetGallery; |
| 21 | + |
| 22 | +namespace GalleryTools.Commands |
| 23 | +{ |
| 24 | + /// <summary> |
| 25 | + /// This command verifies that the <see cref="PackageVulnerability"/> and <see cref="VulnerablePackageVersionRange"/> entities in the |
| 26 | + /// database match the <see cref="SecurityAdvisory"/> and <see cref="SecurityVulnerability"/> entities in GitHub's V4 GraphQL API. |
| 27 | + /// </summary> |
| 28 | + /// <remarks> |
| 29 | + /// The verification only expects that advisories that are present in the GitHub API have the same metadata and contain the same ranges in the DB. |
| 30 | + /// It intentionally does not require that all vulnerabilities in the DB come from GitHub, or that the set of ranges in the DB match the set of ranges in the GitHub API. |
| 31 | + /// This is so that we can add some additional vulnerabilities or ranges for testing or administrative purposes. |
| 32 | + /// </remarks> |
| 33 | + public static class VerifyGitHubVulnerabilitiesCommand |
| 34 | + { |
| 35 | + public static void Configure(CommandLineApplication config) |
| 36 | + { |
| 37 | + config.Description = "Verify that the gallery database's vulnerability information matches GitHub's feed."; |
| 38 | + config.HelpOption("-? | -h | --help"); |
| 39 | + |
| 40 | + var gitHubPersonalAccessTokenOption = config.Option( |
| 41 | + "--token | -t", |
| 42 | + "The personal access token to use to authenticate with GitHub.", |
| 43 | + CommandOptionType.SingleValue); |
| 44 | + |
| 45 | + var connectionStringOption = config.Option( |
| 46 | + "--connectionstring | -c", |
| 47 | + "The SQL connectionstring of the target NuGetGallery database.", |
| 48 | + CommandOptionType.SingleValue); |
| 49 | + |
| 50 | + config.OnExecute(async () => await ExecuteAsync( |
| 51 | + connectionStringOption, |
| 52 | + gitHubPersonalAccessTokenOption)); |
| 53 | + } |
| 54 | + |
| 55 | + private static async Task<int> ExecuteAsync( |
| 56 | + CommandOption connectionStringOption, |
| 57 | + CommandOption gitHubPersonalAccessTokenOption) |
| 58 | + { |
| 59 | + if (!connectionStringOption.HasValue()) |
| 60 | + { |
| 61 | + Console.Error.WriteLine($"The {connectionStringOption.Template} option is required."); |
| 62 | + return 1; |
| 63 | + } |
| 64 | + |
| 65 | + if (!gitHubPersonalAccessTokenOption.HasValue()) |
| 66 | + { |
| 67 | + Console.Error.WriteLine($"The {connectionStringOption.Template} option is required."); |
| 68 | + return 1; |
| 69 | + } |
| 70 | + |
| 71 | + try |
| 72 | + { |
| 73 | + var advisories = await FetchAdvisories(gitHubPersonalAccessTokenOption.Value()); |
| 74 | + await VerifyPackageVulnerabilities( |
| 75 | + connectionStringOption.Value(), |
| 76 | + advisories); |
| 77 | + |
| 78 | + Console.WriteLine("DONE"); |
| 79 | + return 0; |
| 80 | + } |
| 81 | + catch (Exception e) |
| 82 | + { |
| 83 | + Console.WriteLine(" FAILED"); |
| 84 | + Console.Error.WriteLine(e.Message); |
| 85 | + return 1; |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + private static async Task<IReadOnlyList<SecurityAdvisory>> FetchAdvisories( |
| 90 | + string token) |
| 91 | + { |
| 92 | + Console.Write("Fetching vulnerabilities from GitHub..."); |
| 93 | + |
| 94 | + var config = new GitHubVulnerabilities2DbConfiguration |
| 95 | + { |
| 96 | + GitHubPersonalAccessToken = token |
| 97 | + }; |
| 98 | + |
| 99 | + var queryService = new QueryService( |
| 100 | + config, |
| 101 | + new HttpClient()); |
| 102 | + |
| 103 | + var advisoryQueryService = new AdvisoryQueryService( |
| 104 | + queryService, |
| 105 | + new AdvisoryQueryBuilder(), |
| 106 | + NullLogger<AdvisoryQueryService>.Instance); |
| 107 | + |
| 108 | + var advisories = await advisoryQueryService.GetAdvisoriesSinceAsync(DateTimeOffset.MinValue, CancellationToken.None); |
| 109 | + Console.WriteLine($" FOUND {advisories.Count} advisories."); |
| 110 | + return advisories; |
| 111 | + } |
| 112 | + |
| 113 | + private static async Task VerifyPackageVulnerabilities( |
| 114 | + string connectionString, |
| 115 | + IReadOnlyList<SecurityAdvisory> advisories) |
| 116 | + { |
| 117 | + Console.WriteLine("Fetching vulnerabilities from DB..."); |
| 118 | + |
| 119 | + using (var sqlConnection = new SqlConnection(connectionString)) |
| 120 | + { |
| 121 | + await sqlConnection.OpenAsync(); |
| 122 | + using (var entitiesContext = new EntitiesContext(sqlConnection, readOnly: false)) |
| 123 | + { |
| 124 | + var verifier = new PackageVulnerabilityServiceVerifier(entitiesContext); |
| 125 | + var ingestor = new AdvisoryIngestor(verifier, new GitHubVersionRangeParser()); |
| 126 | + await ingestor.IngestAsync(advisories); |
| 127 | + |
| 128 | + if (verifier.HasErrors) |
| 129 | + { |
| 130 | + throw new Exception("DB does not match GitHub API!"); |
| 131 | + } |
| 132 | + |
| 133 | + Console.WriteLine("DB matches GitHub API!"); |
| 134 | + } |
| 135 | + } |
| 136 | + } |
| 137 | + |
| 138 | + public class PackageVulnerabilityServiceVerifier : IPackageVulnerabilityService |
| 139 | + { |
| 140 | + private readonly IEntitiesContext _entitiesContext; |
| 141 | + |
| 142 | + public PackageVulnerabilityServiceVerifier( |
| 143 | + IEntitiesContext entitiesContext) |
| 144 | + { |
| 145 | + _entitiesContext = entitiesContext ?? throw new ArgumentNullException(nameof(entitiesContext)); |
| 146 | + } |
| 147 | + |
| 148 | + public void ApplyExistingVulnerabilitiesToPackage(Package package) |
| 149 | + { |
| 150 | + throw new NotImplementedException(); |
| 151 | + } |
| 152 | + |
| 153 | + public Task UpdateVulnerabilityAsync(PackageVulnerability vulnerability, bool withdrawn) |
| 154 | + { |
| 155 | + Console.WriteLine($"Verifying vulnerability {vulnerability.GitHubDatabaseKey}."); |
| 156 | + var existingVulnerability = _entitiesContext.Vulnerabilities |
| 157 | + .Include(v => v.AffectedRanges) |
| 158 | + .SingleOrDefault(v => v.GitHubDatabaseKey == vulnerability.GitHubDatabaseKey); |
| 159 | + |
| 160 | + if (withdrawn || !vulnerability.AffectedRanges.Any()) |
| 161 | + { |
| 162 | + if (existingVulnerability != null) |
| 163 | + { |
| 164 | + Console.Error.WriteLine(withdrawn ? |
| 165 | + $@"Vulnerability advisory {vulnerability.GitHubDatabaseKey} was withdrawn and should not be in DB!" : |
| 166 | + $@"Vulnerability advisory {vulnerability.GitHubDatabaseKey} affects no packages and should not be in DB!"); |
| 167 | + HasErrors = true; |
| 168 | + } |
| 169 | + |
| 170 | + return Task.CompletedTask; |
| 171 | + } |
| 172 | + |
| 173 | + if (existingVulnerability == null) |
| 174 | + { |
| 175 | + Console.Error.WriteLine($"Cannot find vulnerability {vulnerability.GitHubDatabaseKey} in DB!"); |
| 176 | + HasErrors = true; |
| 177 | + return Task.CompletedTask; |
| 178 | + } |
| 179 | + |
| 180 | + if (existingVulnerability.Severity != vulnerability.Severity) |
| 181 | + { |
| 182 | + Console.Error.WriteLine( |
| 183 | + $@"Vulnerability advisory {vulnerability.GitHubDatabaseKey |
| 184 | + }, severity does not match! GitHub: {vulnerability.Severity}, DB: {existingVulnerability.Severity}"); |
| 185 | + HasErrors = true; |
| 186 | + } |
| 187 | + |
| 188 | + if (existingVulnerability.AdvisoryUrl != vulnerability.AdvisoryUrl) |
| 189 | + { |
| 190 | + Console.Error.WriteLine( |
| 191 | + $@"Vulnerability advisory {vulnerability.GitHubDatabaseKey |
| 192 | + }, advisory URL does not match! GitHub: {vulnerability.AdvisoryUrl}, DB: { existingVulnerability.AdvisoryUrl}"); |
| 193 | + HasErrors = true; |
| 194 | + } |
| 195 | + |
| 196 | + foreach (var range in vulnerability.AffectedRanges) |
| 197 | + { |
| 198 | + Console.WriteLine($"Verifying range affecting {range.PackageId} {range.PackageVersionRange}."); |
| 199 | + var existingRange = existingVulnerability.AffectedRanges |
| 200 | + .SingleOrDefault(r => r.PackageId == range.PackageId && r.PackageVersionRange == range.PackageVersionRange); |
| 201 | + |
| 202 | + if (existingRange == null) |
| 203 | + { |
| 204 | + Console.Error.WriteLine( |
| 205 | + $@"Vulnerability advisory {vulnerability.GitHubDatabaseKey |
| 206 | + }, cannot find range {range.PackageId} {range.PackageVersionRange} in DB!"); |
| 207 | + HasErrors = true; |
| 208 | + continue; |
| 209 | + } |
| 210 | + |
| 211 | + if (existingRange.FirstPatchedPackageVersion != range.FirstPatchedPackageVersion) |
| 212 | + { |
| 213 | + Console.Error.WriteLine( |
| 214 | + $@"Vulnerability advisory {vulnerability.GitHubDatabaseKey |
| 215 | + }, range {range.PackageId} {range.PackageVersionRange}, first patched version does not match! GitHub: { |
| 216 | + range.FirstPatchedPackageVersion}, DB: {range.FirstPatchedPackageVersion}"); |
| 217 | + HasErrors = true; |
| 218 | + } |
| 219 | + |
| 220 | + var packages = _entitiesContext.Packages |
| 221 | + .Where(p => p.PackageRegistration.Id == range.PackageId) |
| 222 | + .Include(p => p.Vulnerabilities) |
| 223 | + .ToList(); |
| 224 | + |
| 225 | + var versionRange = VersionRange.Parse(range.PackageVersionRange); |
| 226 | + foreach (var package in packages) |
| 227 | + { |
| 228 | + var version = NuGetVersion.Parse(package.NormalizedVersion); |
| 229 | + if (versionRange.Satisfies(version) != package.Vulnerabilities.Contains(existingRange)) |
| 230 | + { |
| 231 | + Console.Error.WriteLine( |
| 232 | + $@"Vulnerability advisory {vulnerability.GitHubDatabaseKey |
| 233 | + }, range {range.PackageId} {range.PackageVersionRange}, package {package.NormalizedVersion |
| 234 | + } is not properly marked vulnerable to vulnerability!"); |
| 235 | + HasErrors = true; |
| 236 | + } |
| 237 | + } |
| 238 | + } |
| 239 | + |
| 240 | + return Task.CompletedTask; |
| 241 | + } |
| 242 | + |
| 243 | + public bool HasErrors { get; private set; } |
| 244 | + } |
| 245 | + } |
| 246 | +} |
0 commit comments