diff --git a/src/Octoshift/DependabotAlertState.cs b/src/Octoshift/DependabotAlertState.cs new file mode 100644 index 000000000..1793622b4 --- /dev/null +++ b/src/Octoshift/DependabotAlertState.cs @@ -0,0 +1,23 @@ +namespace OctoshiftCLI; + +public static class DependabotAlertState +{ + public const string Open = "open"; + public const string Dismissed = "dismissed"; + public const string Fixed = "fixed"; + + // Dismissal reasons for Dependabot alerts + public const string FalsePositive = "false_positive"; + public const string Inaccurate = "inaccurate"; + public const string NotUsed = "not_used"; + public const string NoBandwidth = "no_bandwidth"; + public const string TolerableRisk = "tolerable_risk"; + + public static bool IsOpenOrDismissed(string state) => state?.Trim().ToLower() is Open or Dismissed; + + public static bool IsDismissed(string state) => state?.Trim().ToLower() is Dismissed; + + public static bool IsValidDismissedReason(string reason) => + reason?.Trim().ToLower() is + FalsePositive or Inaccurate or NotUsed or NoBandwidth or TolerableRisk; +} diff --git a/src/Octoshift/Models/DependabotAlert.cs b/src/Octoshift/Models/DependabotAlert.cs new file mode 100644 index 000000000..8bfb21c71 --- /dev/null +++ b/src/Octoshift/Models/DependabotAlert.cs @@ -0,0 +1,41 @@ +namespace Octoshift.Models; + +public class DependabotAlert +{ + public int Number { get; set; } + public string State { get; set; } + public string DismissedReason { get; set; } + public string DismissedComment { get; set; } + public string DismissedAt { get; set; } + public DependabotAlertDependency Dependency { get; set; } + public DependabotAlertSecurityAdvisory SecurityAdvisory { get; set; } + public DependabotAlertSecurityVulnerability SecurityVulnerability { get; set; } + public string Url { get; set; } + public string HtmlUrl { get; set; } + public string CreatedAt { get; set; } + public string UpdatedAt { get; set; } +} + +public class DependabotAlertDependency +{ + public string Package { get; set; } + public string Manifest { get; set; } + public string Scope { get; set; } +} + +public class DependabotAlertSecurityAdvisory +{ + public string GhsaId { get; set; } + public string CveId { get; set; } + public string Summary { get; set; } + public string Description { get; set; } + public string Severity { get; set; } +} + +public class DependabotAlertSecurityVulnerability +{ + public string Package { get; set; } + public string Severity { get; set; } + public string VulnerableVersionRange { get; set; } + public string FirstPatchedVersion { get; set; } +} diff --git a/src/Octoshift/Services/DependabotAlertService.cs b/src/Octoshift/Services/DependabotAlertService.cs new file mode 100644 index 000000000..92c910cdd --- /dev/null +++ b/src/Octoshift/Services/DependabotAlertService.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Octoshift.Models; + +namespace OctoshiftCLI.Services; + +public class DependabotAlertService +{ + private readonly GithubApi _sourceGithubApi; + private readonly GithubApi _targetGithubApi; + private readonly OctoLogger _log; + + public DependabotAlertService(GithubApi sourceGithubApi, GithubApi targetGithubApi, OctoLogger octoLogger) + { + _sourceGithubApi = sourceGithubApi; + _targetGithubApi = targetGithubApi; + _log = octoLogger; + } + + public virtual async Task MigrateDependabotAlerts(string sourceOrg, string sourceRepo, string targetOrg, + string targetRepo, bool dryRun) + { + _log.LogInformation($"Migrating Dependabot Alerts from '{sourceOrg}/{sourceRepo}' to '{targetOrg}/{targetRepo}'"); + + var sourceAlerts = (await _sourceGithubApi.GetDependabotAlertsForRepository(sourceOrg, sourceRepo)).ToList(); + var targetAlerts = (await _targetGithubApi.GetDependabotAlertsForRepository(targetOrg, targetRepo)).ToList(); + + var successCount = 0; + var skippedCount = 0; + var notFoundCount = 0; + + _log.LogInformation($"Found {sourceAlerts.Count} source and {targetAlerts.Count} target alerts. Starting migration of alert states..."); + + foreach (var sourceAlert in sourceAlerts) + { + if (!DependabotAlertState.IsOpenOrDismissed(sourceAlert.State)) + { + _log.LogInformation($" skipping alert {sourceAlert.Number} ({sourceAlert.Url}) because state '{sourceAlert.State}' is not migratable."); + skippedCount++; + continue; + } + + var matchingTargetAlert = FindMatchingTargetAlert(targetAlerts, sourceAlert); + + if (matchingTargetAlert == null) + { + _log.LogError($" could not find a target alert for {sourceAlert.Number} ({sourceAlert.Url})."); + notFoundCount++; + continue; + } + + if (matchingTargetAlert.State == sourceAlert.State) + { + _log.LogInformation(" skipping alert because target alert already has the same state."); + skippedCount++; + continue; + } + + if (dryRun) + { + _log.LogInformation($" running in dry-run mode. Would update target alert {matchingTargetAlert.Number} ({matchingTargetAlert.Url}) from state '{matchingTargetAlert.State}' to '{sourceAlert.State}'"); + successCount++; + continue; + } + + _log.LogVerbose($"Setting Status {sourceAlert.State} for target alert {matchingTargetAlert.Number} ({matchingTargetAlert.Url})"); + await _targetGithubApi.UpdateDependabotAlert( + targetOrg, + targetRepo, + matchingTargetAlert.Number, + sourceAlert.State, + sourceAlert.DismissedReason, + sourceAlert.DismissedComment + ); + successCount++; + } + + _log.LogInformation($"Dependabot Alerts done!\nStatus of {sourceAlerts.Count} Alerts:\n Success: {successCount}\n Skipped (status not migratable or already matches): {skippedCount}\n No matching target found (see logs): {notFoundCount}."); + + if (notFoundCount > 0) + { + throw new OctoshiftCliException("Migration of Dependabot Alerts failed."); + } + } + + private DependabotAlert FindMatchingTargetAlert(List targetAlerts, DependabotAlert sourceAlert) + { + // Try to match based on the security advisory GHSA ID and package name + var matchingAlert = targetAlerts.FirstOrDefault(targetAlert => + targetAlert.SecurityAdvisory?.GhsaId == sourceAlert.SecurityAdvisory?.GhsaId && + targetAlert.Dependency?.Package == sourceAlert.Dependency?.Package && + targetAlert.Dependency?.Manifest == sourceAlert.Dependency?.Manifest); + + if (matchingAlert != null) + { + return matchingAlert; + } + + // Fall back to matching by CVE ID if GHSA ID doesn't match + return targetAlerts.FirstOrDefault(targetAlert => + targetAlert.SecurityAdvisory?.CveId == sourceAlert.SecurityAdvisory?.CveId && + targetAlert.Dependency?.Package == sourceAlert.Dependency?.Package && + targetAlert.Dependency?.Manifest == sourceAlert.Dependency?.Manifest); + } +} diff --git a/src/Octoshift/Services/GithubApi.cs b/src/Octoshift/Services/GithubApi.cs index 051c06b1f..a2237ced0 100644 --- a/src/Octoshift/Services/GithubApi.cs +++ b/src/Octoshift/Services/GithubApi.cs @@ -1039,6 +1039,39 @@ public virtual async Task> GetCodeScannin .ToListAsync(); } + public virtual async Task> GetDependabotAlertsForRepository(string org, string repo) + { + var url = $"{_apiUrl}/repos/{org.EscapeDataString()}/{repo.EscapeDataString()}/dependabot/alerts?per_page=100"; + return await _client.GetAllAsync(url) + .Select(BuildDependabotAlert) + .ToListAsync(); + } + + public virtual async Task UpdateDependabotAlert(string org, string repo, int alertNumber, string state, string dismissedReason = null, string dismissedComment = null) + { + if (!DependabotAlertState.IsOpenOrDismissed(state)) + { + throw new ArgumentException($"Invalid value for {nameof(state)}"); + } + + if (DependabotAlertState.IsDismissed(state) && !DependabotAlertState.IsValidDismissedReason(dismissedReason)) + { + throw new ArgumentException($"Invalid value for {nameof(dismissedReason)}"); + } + + var url = $"{_apiUrl}/repos/{org.EscapeDataString()}/{repo.EscapeDataString()}/dependabot/alerts/{alertNumber}"; + + var payload = state == "open" + ? (new { state }) + : (object)(new + { + state, + dismissed_reason = dismissedReason, + dismissed_comment = dismissedComment ?? string.Empty + }); + await _client.PatchAsync(url, payload); + } + public virtual async Task GetEnterpriseServerVersion() { var url = $"{_apiUrl}/meta"; @@ -1246,4 +1279,48 @@ private static CodeScanningAlertInstance BuildCodeScanningAlertInstance(JToken s StartColumn = (int)scanningAlertInstance["location"]["start_column"], EndColumn = (int)scanningAlertInstance["location"]["end_column"] }; + + private static DependabotAlert BuildDependabotAlert(JToken dependabotAlert) => + new() + { + Number = (int)dependabotAlert["number"], + State = (string)dependabotAlert["state"], + DismissedReason = dependabotAlert.Value("dismissed_reason"), + DismissedComment = dependabotAlert.Value("dismissed_comment"), + DismissedAt = dependabotAlert.Value("dismissed_at"), + Url = (string)dependabotAlert["url"], + HtmlUrl = (string)dependabotAlert["html_url"], + CreatedAt = (string)dependabotAlert["created_at"], + UpdatedAt = (string)dependabotAlert["updated_at"], + Dependency = BuildDependabotAlertDependency(dependabotAlert["dependency"]), + SecurityAdvisory = BuildDependabotAlertSecurityAdvisory(dependabotAlert["security_advisory"]), + SecurityVulnerability = BuildDependabotAlertSecurityVulnerability(dependabotAlert["security_vulnerability"]) + }; + + private static DependabotAlertDependency BuildDependabotAlertDependency(JToken dependency) => + new() + { + Package = (string)dependency["package"]?["name"], + Manifest = (string)dependency["manifest_path"], + Scope = (string)dependency["scope"] + }; + + private static DependabotAlertSecurityAdvisory BuildDependabotAlertSecurityAdvisory(JToken securityAdvisory) => + new() + { + GhsaId = (string)securityAdvisory["ghsa_id"], + CveId = (string)securityAdvisory["cve_id"], + Summary = (string)securityAdvisory["summary"], + Description = (string)securityAdvisory["description"], + Severity = (string)securityAdvisory["severity"] + }; + + private static DependabotAlertSecurityVulnerability BuildDependabotAlertSecurityVulnerability(JToken securityVulnerability) => + new() + { + Package = (string)securityVulnerability["package"]?["name"], + Severity = (string)securityVulnerability["severity"], + VulnerableVersionRange = (string)securityVulnerability["vulnerable_version_range"], + FirstPatchedVersion = (string)securityVulnerability["first_patched_version"]?["identifier"] + }; } diff --git a/src/OctoshiftCLI.Tests/Octoshift/Services/DependabotAlertServiceTests.cs b/src/OctoshiftCLI.Tests/Octoshift/Services/DependabotAlertServiceTests.cs new file mode 100644 index 000000000..7cb0f1e93 --- /dev/null +++ b/src/OctoshiftCLI.Tests/Octoshift/Services/DependabotAlertServiceTests.cs @@ -0,0 +1,281 @@ +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Octoshift.Models; +using OctoshiftCLI.Services; +using Xunit; + +namespace OctoshiftCLI.Tests.Octoshift.Services; + +public class DependabotAlertServiceTests +{ + private readonly Mock _mockSourceGithubApi = TestHelpers.CreateMock(); + private readonly Mock _mockTargetGithubApi = TestHelpers.CreateMock(); + private readonly Mock _mockOctoLogger = TestHelpers.CreateMock(); + + private readonly DependabotAlertService _alertService; + + private const string SOURCE_ORG = "SOURCE-ORG"; + private const string SOURCE_REPO = "SOURCE-REPO"; + private const string TARGET_ORG = "TARGET-ORG"; + private const string TARGET_REPO = "TARGET-REPO"; + + public DependabotAlertServiceTests() + { + _alertService = new DependabotAlertService(_mockSourceGithubApi.Object, _mockTargetGithubApi.Object, _mockOctoLogger.Object); + } + + [Fact] + public async Task MigrateDependabotAlerts_No_Alerts_Does_Not_Update_Any_Alerts() + { + // Arrange + _mockSourceGithubApi.Setup(x => x.GetDependabotAlertsForRepository(SOURCE_ORG, SOURCE_REPO)).ReturnsAsync(Enumerable.Empty()); + _mockTargetGithubApi.Setup(x => x.GetDependabotAlertsForRepository(TARGET_ORG, TARGET_REPO)).ReturnsAsync(Enumerable.Empty()); + + // Act + await _alertService.MigrateDependabotAlerts(SOURCE_ORG, SOURCE_REPO, TARGET_ORG, TARGET_REPO, false); + + // Assert + _mockTargetGithubApi.Verify(x => x.UpdateDependabotAlert(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task MigrateDependabotAlerts_Matches_Alerts_By_Ghsa_Id_And_Package() + { + // Arrange + var sourceAlert = new DependabotAlert + { + Number = 1, + State = "dismissed", + DismissedReason = "false_positive", + DismissedComment = "Not applicable", + Url = "https://api.github.com/repos/source-org/source-repo/dependabot/alerts/1", + SecurityAdvisory = new DependabotAlertSecurityAdvisory { GhsaId = "GHSA-1234-5678-9abc" }, + Dependency = new DependabotAlertDependency { Package = "lodash", Manifest = "package.json" } + }; + + var targetAlert = new DependabotAlert + { + Number = 2, + State = "open", + Url = "https://api.github.com/repos/target-org/target-repo/dependabot/alerts/2", + SecurityAdvisory = new DependabotAlertSecurityAdvisory { GhsaId = "GHSA-1234-5678-9abc" }, + Dependency = new DependabotAlertDependency { Package = "lodash", Manifest = "package.json" } + }; + + _mockSourceGithubApi.Setup(x => x.GetDependabotAlertsForRepository(SOURCE_ORG, SOURCE_REPO)).ReturnsAsync(new[] { sourceAlert }); + _mockTargetGithubApi.Setup(x => x.GetDependabotAlertsForRepository(TARGET_ORG, TARGET_REPO)).ReturnsAsync(new[] { targetAlert }); + + // Act + await _alertService.MigrateDependabotAlerts(SOURCE_ORG, SOURCE_REPO, TARGET_ORG, TARGET_REPO, false); + + // Assert + _mockTargetGithubApi.Verify(x => x.UpdateDependabotAlert( + TARGET_ORG, + TARGET_REPO, + targetAlert.Number, + sourceAlert.State, + sourceAlert.DismissedReason, + sourceAlert.DismissedComment + ), Times.Once); + } + + [Fact] + public async Task MigrateDependabotAlerts_Falls_Back_To_Cve_Id_When_Ghsa_Id_Does_Not_Match() + { + // Arrange + var sourceAlert = new DependabotAlert + { + Number = 1, + State = "dismissed", + DismissedReason = "not_used", + DismissedComment = "Package not used in production", + Url = "https://api.github.com/repos/source-org/source-repo/dependabot/alerts/1", + SecurityAdvisory = new DependabotAlertSecurityAdvisory { GhsaId = "GHSA-1111-1111-1111", CveId = "CVE-2023-1234" }, + Dependency = new DependabotAlertDependency { Package = "express", Manifest = "package.json" } + }; + + var targetAlert = new DependabotAlert + { + Number = 3, + State = "open", + Url = "https://api.github.com/repos/target-org/target-repo/dependabot/alerts/3", + SecurityAdvisory = new DependabotAlertSecurityAdvisory { GhsaId = "GHSA-2222-2222-2222", CveId = "CVE-2023-1234" }, + Dependency = new DependabotAlertDependency { Package = "express", Manifest = "package.json" } + }; + + _mockSourceGithubApi.Setup(x => x.GetDependabotAlertsForRepository(SOURCE_ORG, SOURCE_REPO)).ReturnsAsync(new[] { sourceAlert }); + _mockTargetGithubApi.Setup(x => x.GetDependabotAlertsForRepository(TARGET_ORG, TARGET_REPO)).ReturnsAsync(new[] { targetAlert }); + + // Act + await _alertService.MigrateDependabotAlerts(SOURCE_ORG, SOURCE_REPO, TARGET_ORG, TARGET_REPO, false); + + // Assert + _mockTargetGithubApi.Verify(x => x.UpdateDependabotAlert( + TARGET_ORG, + TARGET_REPO, + targetAlert.Number, + sourceAlert.State, + sourceAlert.DismissedReason, + sourceAlert.DismissedComment + ), Times.Once); + } + + [Fact] + public async Task MigrateDependabotAlerts_Skips_Non_Migratable_States() + { + // Arrange + var sourceAlert = new DependabotAlert + { + Number = 1, + State = "fixed", // non-migratable state + Url = "https://api.github.com/repos/source-org/source-repo/dependabot/alerts/1", + SecurityAdvisory = new DependabotAlertSecurityAdvisory { GhsaId = "GHSA-1234-5678-9abc" }, + Dependency = new DependabotAlertDependency { Package = "lodash", Manifest = "package.json" } + }; + + _mockSourceGithubApi.Setup(x => x.GetDependabotAlertsForRepository(SOURCE_ORG, SOURCE_REPO)).ReturnsAsync(new[] { sourceAlert }); + _mockTargetGithubApi.Setup(x => x.GetDependabotAlertsForRepository(TARGET_ORG, TARGET_REPO)).ReturnsAsync(Enumerable.Empty()); + + // Act + await _alertService.MigrateDependabotAlerts(SOURCE_ORG, SOURCE_REPO, TARGET_ORG, TARGET_REPO, false); + + // Assert + _mockTargetGithubApi.Verify(x => x.UpdateDependabotAlert(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task MigrateDependabotAlerts_Skips_When_Target_Already_Has_Same_State() + { + // Arrange + var sourceAlert = new DependabotAlert + { + Number = 1, + State = "dismissed", + DismissedReason = "false_positive", + DismissedComment = "Not applicable", + Url = "https://api.github.com/repos/source-org/source-repo/dependabot/alerts/1", + SecurityAdvisory = new DependabotAlertSecurityAdvisory { GhsaId = "GHSA-1234-5678-9abc" }, + Dependency = new DependabotAlertDependency { Package = "lodash", Manifest = "package.json" } + }; + + var targetAlert = new DependabotAlert + { + Number = 2, + State = "dismissed", // Same state as source + Url = "https://api.github.com/repos/target-org/target-repo/dependabot/alerts/2", + SecurityAdvisory = new DependabotAlertSecurityAdvisory { GhsaId = "GHSA-1234-5678-9abc" }, + Dependency = new DependabotAlertDependency { Package = "lodash", Manifest = "package.json" } + }; + + _mockSourceGithubApi.Setup(x => x.GetDependabotAlertsForRepository(SOURCE_ORG, SOURCE_REPO)).ReturnsAsync(new[] { sourceAlert }); + _mockTargetGithubApi.Setup(x => x.GetDependabotAlertsForRepository(TARGET_ORG, TARGET_REPO)).ReturnsAsync(new[] { targetAlert }); + + // Act + await _alertService.MigrateDependabotAlerts(SOURCE_ORG, SOURCE_REPO, TARGET_ORG, TARGET_REPO, false); + + // Assert + _mockTargetGithubApi.Verify(x => x.UpdateDependabotAlert(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task MigrateDependabotAlerts_Throws_When_Target_Alert_Not_Found() + { + // Arrange + var sourceAlert = new DependabotAlert + { + Number = 1, + State = "dismissed", + DismissedReason = "false_positive", + Url = "https://api.github.com/repos/source-org/source-repo/dependabot/alerts/1", + SecurityAdvisory = new DependabotAlertSecurityAdvisory { GhsaId = "GHSA-1234-5678-9abc" }, + Dependency = new DependabotAlertDependency { Package = "lodash", Manifest = "package.json" } + }; + + // No matching target alert + var targetAlert = new DependabotAlert + { + Number = 2, + State = "open", + Url = "https://api.github.com/repos/target-org/target-repo/dependabot/alerts/2", + SecurityAdvisory = new DependabotAlertSecurityAdvisory { GhsaId = "GHSA-9999-9999-9999" }, // Different GHSA ID + Dependency = new DependabotAlertDependency { Package = "react", Manifest = "package.json" } // Different package + }; + + _mockSourceGithubApi.Setup(x => x.GetDependabotAlertsForRepository(SOURCE_ORG, SOURCE_REPO)).ReturnsAsync(new[] { sourceAlert }); + _mockTargetGithubApi.Setup(x => x.GetDependabotAlertsForRepository(TARGET_ORG, TARGET_REPO)).ReturnsAsync(new[] { targetAlert }); + + // Act & Assert + await _alertService.Invoking(x => x.MigrateDependabotAlerts(SOURCE_ORG, SOURCE_REPO, TARGET_ORG, TARGET_REPO, false)) + .Should().ThrowAsync() + .WithMessage("Migration of Dependabot Alerts failed."); + } + + [Fact] + public async Task MigrateDependabotAlerts_In_Dry_Run_Mode_Gets_Target_Alerts_But_Does_Not_Update() + { + // Arrange + var sourceAlert = new DependabotAlert + { + Number = 1, + State = "dismissed", + DismissedReason = "false_positive", + Url = "https://api.github.com/repos/source-org/source-repo/dependabot/alerts/1", + SecurityAdvisory = new DependabotAlertSecurityAdvisory { GhsaId = "GHSA-1234-5678-9abc" }, + Dependency = new DependabotAlertDependency { Package = "lodash", Manifest = "package.json" } + }; + + var targetAlert = new DependabotAlert + { + Number = 2, + State = "open", + Url = "https://api.github.com/repos/target-org/target-repo/dependabot/alerts/2", + SecurityAdvisory = new DependabotAlertSecurityAdvisory { GhsaId = "GHSA-1234-5678-9abc" }, + Dependency = new DependabotAlertDependency { Package = "lodash", Manifest = "package.json" } + }; + + _mockSourceGithubApi.Setup(x => x.GetDependabotAlertsForRepository(SOURCE_ORG, SOURCE_REPO)).ReturnsAsync(new[] { sourceAlert }); + _mockTargetGithubApi.Setup(x => x.GetDependabotAlertsForRepository(TARGET_ORG, TARGET_REPO)).ReturnsAsync(new[] { targetAlert }); + + // Act + await _alertService.MigrateDependabotAlerts(SOURCE_ORG, SOURCE_REPO, TARGET_ORG, TARGET_REPO, true); + + // Assert + _mockTargetGithubApi.Verify(x => x.GetDependabotAlertsForRepository(TARGET_ORG, TARGET_REPO), Times.Once); + _mockTargetGithubApi.Verify(x => x.UpdateDependabotAlert(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task MigrateDependabotAlerts_In_Dry_Run_Mode_Still_Throws_When_Target_Alert_Not_Found() + { + // Arrange + var sourceAlert = new DependabotAlert + { + Number = 1, + State = "dismissed", + DismissedReason = "false_positive", + Url = "https://api.github.com/repos/source-org/source-repo/dependabot/alerts/1", + SecurityAdvisory = new DependabotAlertSecurityAdvisory { GhsaId = "GHSA-1234-5678-9abc" }, + Dependency = new DependabotAlertDependency { Package = "lodash", Manifest = "package.json" } + }; + + // No matching target alert + var targetAlert = new DependabotAlert + { + Number = 2, + State = "open", + Url = "https://api.github.com/repos/target-org/target-repo/dependabot/alerts/2", + SecurityAdvisory = new DependabotAlertSecurityAdvisory { GhsaId = "GHSA-9999-9999-9999" }, // Different GHSA ID + Dependency = new DependabotAlertDependency { Package = "react", Manifest = "package.json" } // Different package + }; + + _mockSourceGithubApi.Setup(x => x.GetDependabotAlertsForRepository(SOURCE_ORG, SOURCE_REPO)).ReturnsAsync(new[] { sourceAlert }); + _mockTargetGithubApi.Setup(x => x.GetDependabotAlertsForRepository(TARGET_ORG, TARGET_REPO)).ReturnsAsync(new[] { targetAlert }); + + // Act & Assert + await _alertService.Invoking(x => x.MigrateDependabotAlerts(SOURCE_ORG, SOURCE_REPO, TARGET_ORG, TARGET_REPO, true)) + .Should().ThrowAsync() + .WithMessage("Migration of Dependabot Alerts failed."); + } +} diff --git a/src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs b/src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs index 97a2a22aa..2676bcfb7 100644 --- a/src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs +++ b/src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs @@ -3868,6 +3868,168 @@ public async Task GetSecretScanningAlertsForRepository_Populates_ResolutionComme array[0].ResolverName.Should().Be("actor"); } + [Fact] + public async Task GetDependabotAlertsForRepository_Returns_Correct_Data() + { + // Arrange + const string url = $"https://api.github.com/repos/{GITHUB_ORG}/{GITHUB_REPO}/dependabot/alerts?per_page=100"; + + var dependabotAlert = $@" + {{ + ""number"": 1, + ""state"": ""dismissed"", + ""dismissed_reason"": ""false_positive"", + ""dismissed_comment"": ""Not applicable"", + ""dismissed_at"": ""2023-05-15T10:30:00Z"", + ""url"": ""https://api.github.com/repos/{GITHUB_ORG}/{GITHUB_REPO}/dependabot/alerts/1"", + ""html_url"": ""https://github.com/{GITHUB_ORG}/{GITHUB_REPO}/security/dependabot/1"", + ""created_at"": ""2023-05-14T08:00:00Z"", + ""updated_at"": ""2023-05-15T10:30:00Z"", + ""dependency"": {{ + ""package"": {{ + ""name"": ""lodash"" + }}, + ""manifest_path"": ""package.json"", + ""scope"": ""runtime"" + }}, + ""security_advisory"": {{ + ""ghsa_id"": ""GHSA-1234-5678-9abc"", + ""cve_id"": ""CVE-2023-1234"", + ""summary"": ""Prototype Pollution in lodash"", + ""description"": ""lodash is vulnerable to prototype pollution"", + ""severity"": ""high"" + }}, + ""security_vulnerability"": {{ + ""package"": {{ + ""name"": ""lodash"" + }}, + ""severity"": ""high"", + ""vulnerable_version_range"": ""< 4.17.21"", + ""first_patched_version"": {{ + ""identifier"": ""4.17.21"" + }} + }} + }}"; + + var alerts = new[] { JToken.Parse(dependabotAlert) }; + + _githubClientMock + .Setup(m => m.GetAllAsync(url, null)) + .Returns(alerts.ToAsyncEnumerable()); + + // Act + var results = await _githubApi.GetDependabotAlertsForRepository(GITHUB_ORG, GITHUB_REPO); + + // Assert + results.Count().Should().Be(1); + var alert = results.First(); + + alert.Number.Should().Be(1); + alert.State.Should().Be("dismissed"); + alert.DismissedReason.Should().Be("false_positive"); + alert.DismissedComment.Should().Be("Not applicable"); + alert.DismissedAt.Should().NotBeNull(); + alert.Url.Should().Be($"https://api.github.com/repos/{GITHUB_ORG}/{GITHUB_REPO}/dependabot/alerts/1"); + alert.HtmlUrl.Should().Be($"https://github.com/{GITHUB_ORG}/{GITHUB_REPO}/security/dependabot/1"); + alert.CreatedAt.Should().NotBeNull(); + alert.UpdatedAt.Should().NotBeNull(); + + alert.Dependency.Package.Should().Be("lodash"); + alert.Dependency.Manifest.Should().Be("package.json"); + alert.Dependency.Scope.Should().Be("runtime"); + + alert.SecurityAdvisory.GhsaId.Should().Be("GHSA-1234-5678-9abc"); + alert.SecurityAdvisory.CveId.Should().Be("CVE-2023-1234"); + alert.SecurityAdvisory.Summary.Should().Be("Prototype Pollution in lodash"); + alert.SecurityAdvisory.Severity.Should().Be("high"); + + alert.SecurityVulnerability.Package.Should().Be("lodash"); + alert.SecurityVulnerability.Severity.Should().Be("high"); + alert.SecurityVulnerability.VulnerableVersionRange.Should().Be("< 4.17.21"); + alert.SecurityVulnerability.FirstPatchedVersion.Should().Be("4.17.21"); + } + + [Fact] + public async Task UpdateDependabotAlert_Calls_The_Right_Endpoint_With_Payload_For_Open_State() + { + // Arrange + const int alertNumber = 1; + const string state = "open"; + + var url = $"https://api.github.com/repos/{GITHUB_ORG}/{GITHUB_REPO}/dependabot/alerts/{alertNumber}"; + var payload = new { state }; + + // Act + await _githubApi.UpdateDependabotAlert(GITHUB_ORG, GITHUB_REPO, alertNumber, state); + + // Assert + _githubClientMock.Verify(m => m.PatchAsync(url, It.Is(x => x.ToJson() == payload.ToJson()), null)); + } + + [Fact] + public async Task UpdateDependabotAlert_Calls_The_Right_Endpoint_With_Payload_For_Dismissed_State() + { + // Arrange + const int alertNumber = 1; + const string state = "dismissed"; + const string dismissedReason = "false_positive"; + const string dismissedComment = "Not applicable"; + + var url = $"https://api.github.com/repos/{GITHUB_ORG}/{GITHUB_REPO}/dependabot/alerts/{alertNumber}"; + var payload = new { state, dismissed_reason = dismissedReason, dismissed_comment = dismissedComment }; + + // Act + await _githubApi.UpdateDependabotAlert(GITHUB_ORG, GITHUB_REPO, alertNumber, state, dismissedReason, dismissedComment); + + // Assert + _githubClientMock.Verify(m => m.PatchAsync(url, It.Is(x => x.ToJson() == payload.ToJson()), null)); + } + + [Fact] + public async Task UpdateDependabotAlert_Replaces_Null_Dismissed_Comment_With_Empty_String() + { + // Arrange + const int alertNumber = 1; + const string state = "dismissed"; + const string dismissedReason = "not_used"; + + var url = $"https://api.github.com/repos/{GITHUB_ORG}/{GITHUB_REPO}/dependabot/alerts/{alertNumber}"; + var payload = new { state, dismissed_reason = dismissedReason, dismissed_comment = string.Empty }; + + // Act + await _githubApi.UpdateDependabotAlert(GITHUB_ORG, GITHUB_REPO, alertNumber, state, dismissedReason); + + // Assert + _githubClientMock.Verify(m => m.PatchAsync(url, It.Is(x => x.ToJson() == payload.ToJson()), null)); + } + + [Fact] + public async Task UpdateDependabotAlert_Throws_ArgumentException_For_Invalid_State() + { + // Arrange + const int alertNumber = 1; + const string invalidState = "invalid"; + + // Act & Assert + await _githubApi.Invoking(x => x.UpdateDependabotAlert(GITHUB_ORG, GITHUB_REPO, alertNumber, invalidState)) + .Should().ThrowAsync() + .WithMessage("Invalid value for state*"); + } + + [Fact] + public async Task UpdateDependabotAlert_Throws_ArgumentException_For_Invalid_Dismissed_Reason() + { + // Arrange + const int alertNumber = 1; + const string state = "dismissed"; + const string invalidReason = "invalid_reason"; + + // Act & Assert + await _githubApi.Invoking(x => x.UpdateDependabotAlert(GITHUB_ORG, GITHUB_REPO, alertNumber, state, invalidReason)) + .Should().ThrowAsync() + .WithMessage("Invalid value for dismissedReason*"); + } + private string Compact(string source) => source .Replace("\r", "") diff --git a/src/OctoshiftCLI.Tests/gei/Commands/MigrateDependabotAlerts/MigrateDependabotAlertsCommandTests.cs b/src/OctoshiftCLI.Tests/gei/Commands/MigrateDependabotAlerts/MigrateDependabotAlertsCommandTests.cs new file mode 100644 index 000000000..1861f59a8 --- /dev/null +++ b/src/OctoshiftCLI.Tests/gei/Commands/MigrateDependabotAlerts/MigrateDependabotAlertsCommandTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using OctoshiftCLI.GithubEnterpriseImporter.Commands.MigrateDependabotAlerts; +using OctoshiftCLI.GithubEnterpriseImporter.Factories; +using OctoshiftCLI.Services; +using Xunit; + +namespace OctoshiftCLI.Tests.GithubEnterpriseImporter.Commands.MigrateDependabotAlerts; + +public class MigrateDependabotAlertsCommandTests +{ + private readonly Mock _mockEnvironmentVariableProvider = TestHelpers.CreateMock(); + private readonly Mock _mockOctoLogger = TestHelpers.CreateMock(); + private readonly Mock _mockDependabotAlertServiceFactory = TestHelpers.CreateMock(); + + private readonly ServiceProvider _serviceProvider; + private readonly MigrateDependabotAlertsCommand _command = []; + + public MigrateDependabotAlertsCommandTests() + { + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddSingleton(_mockOctoLogger.Object) + .AddSingleton(_mockEnvironmentVariableProvider.Object) + .AddSingleton(_mockDependabotAlertServiceFactory.Object); + + _serviceProvider = serviceCollection.BuildServiceProvider(); + } + + [Fact] + public void Should_Have_Options() + { + _command.Should().NotBeNull(); + _command.Name.Should().Be("migrate-dependabot-alerts"); + _command.Options.Count.Should().Be(11); + + TestHelpers.VerifyCommandOption(_command.Options, "source-org", true); + TestHelpers.VerifyCommandOption(_command.Options, "source-repo", true); + TestHelpers.VerifyCommandOption(_command.Options, "target-org", true); + TestHelpers.VerifyCommandOption(_command.Options, "target-repo", false); + TestHelpers.VerifyCommandOption(_command.Options, "target-api-url", false); + TestHelpers.VerifyCommandOption(_command.Options, "ghes-api-url", false); + TestHelpers.VerifyCommandOption(_command.Options, "no-ssl-verify", false); + TestHelpers.VerifyCommandOption(_command.Options, "github-source-pat", false); + TestHelpers.VerifyCommandOption(_command.Options, "github-target-pat", false); + TestHelpers.VerifyCommandOption(_command.Options, "verbose", false); + TestHelpers.VerifyCommandOption(_command.Options, "dry-run", false); + } + + [Fact] + public void Source_Pat_Should_Default_To_Target_Pat() + { + var targetToken = "target-token"; + + var args = new MigrateDependabotAlertsCommandArgs() + { + SourceOrg = "source-org", + SourceRepo = "source-repo", + TargetOrg = "target-org", + TargetRepo = "target-repo", + GithubTargetPat = targetToken, + }; + + _command.BuildHandler(args, _serviceProvider); + + _mockDependabotAlertServiceFactory.Verify(m => m.Create(It.IsAny(), targetToken, It.IsAny(), targetToken, It.IsAny())); + } +} diff --git a/src/gei/Commands/MigrateDependabotAlerts/MigrateDependabotAlertsCommand.cs b/src/gei/Commands/MigrateDependabotAlerts/MigrateDependabotAlertsCommand.cs new file mode 100644 index 000000000..9aa710866 --- /dev/null +++ b/src/gei/Commands/MigrateDependabotAlerts/MigrateDependabotAlertsCommand.cs @@ -0,0 +1,96 @@ +using System; +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using OctoshiftCLI.Commands; +using OctoshiftCLI.Extensions; +using OctoshiftCLI.GithubEnterpriseImporter.Factories; +using OctoshiftCLI.Services; + +namespace OctoshiftCLI.GithubEnterpriseImporter.Commands.MigrateDependabotAlerts; + +public class MigrateDependabotAlertsCommand : CommandBase +{ + public MigrateDependabotAlertsCommand() : base( + name: "migrate-dependabot-alerts", + description: "Migrates Dependabot alert states and dismissed-reasons. This lets you migrate the dismissal state of Dependabot alerts to the target repository.") + { + AddOption(SourceOrg); + AddOption(SourceRepo); + AddOption(TargetOrg); + AddOption(TargetRepo); + AddOption(TargetApiUrl); + + AddOption(GhesApiUrl); + AddOption(NoSslVerify); + + AddOption(GithubSourcePat); + AddOption(GithubTargetPat); + AddOption(Verbose); + AddOption(DryRun); + } + + public Option SourceOrg { get; } = new("--source-org") { IsRequired = true }; + public Option SourceRepo { get; } = new("--source-repo") { IsRequired = true }; + public Option TargetOrg { get; } = new("--target-org") { IsRequired = true }; + public Option TargetRepo { get; } = new("--target-repo") + { + Description = "Defaults to the name of source-repo" + }; + + public Option TargetApiUrl { get; } = new("--target-api-url") + { + Description = "The URL of the target GitHub instance API. Defaults to https://api.github.com" + }; + public Option GhesApiUrl { get; } = new("--ghes-api-url") + { + Description = + "Required if migrating from GHES. The API endpoint for your GHES instance. For example: http(s)://ghes.contoso.com/api/v3" + }; + public Option NoSslVerify { get; } = new("--no-ssl-verify"); + + public Option GithubSourcePat { get; } = new("--github-source-pat") + { + Description = "Personal access token to use when calling the GitHub source API. Defaults to GH_SOURCE_PAT environment variable." + }; + + public Option GithubTargetPat { get; } = new("--github-target-pat") + { + Description = "Personal access token to use when calling the GitHub target API. Defaults to GH_PAT environment variable." + }; + + public Option Verbose { get; } = new("--verbose"); + + public Option DryRun { get; } = new("--dry-run") + { + Description = + "Execute in dry run mode to see what alerts will be matched and changes applied, but do not make any actual changes." + }; + + public override MigrateDependabotAlertsCommandHandler BuildHandler(MigrateDependabotAlertsCommandArgs args, IServiceProvider sp) + { + if (args is null) + { + throw new ArgumentNullException(nameof(args)); + } + + if (sp is null) + { + throw new ArgumentNullException(nameof(sp)); + } + + var environmentVariableProvider = sp.GetRequiredService(); + args.GithubSourcePat ??= environmentVariableProvider.SourceGithubPersonalAccessToken(false); + args.GithubTargetPat ??= environmentVariableProvider.TargetGithubPersonalAccessToken(); + + if (args.GithubSourcePat.IsNullOrWhiteSpace()) + { + args.GithubSourcePat = args.GithubTargetPat; + } + + var log = sp.GetRequiredService(); + var dependabotAlertServiceFactory = sp.GetRequiredService(); + var dependabotAlertService = dependabotAlertServiceFactory.Create(args.GhesApiUrl, args.GithubSourcePat, args.TargetApiUrl, args.GithubTargetPat, args.NoSslVerify); + + return new MigrateDependabotAlertsCommandHandler(log, dependabotAlertService); + } +} diff --git a/src/gei/Commands/MigrateDependabotAlerts/MigrateDependabotAlertsCommandArgs.cs b/src/gei/Commands/MigrateDependabotAlerts/MigrateDependabotAlertsCommandArgs.cs new file mode 100644 index 000000000..953a07554 --- /dev/null +++ b/src/gei/Commands/MigrateDependabotAlerts/MigrateDependabotAlertsCommandArgs.cs @@ -0,0 +1,30 @@ +using OctoshiftCLI.Commands; +using OctoshiftCLI.Extensions; +using OctoshiftCLI.Services; + +namespace OctoshiftCLI.GithubEnterpriseImporter.Commands.MigrateDependabotAlerts; + +public class MigrateDependabotAlertsCommandArgs : CommandArgs +{ + public string SourceOrg { get; set; } + public string SourceRepo { get; set; } + public string TargetOrg { get; set; } + public string TargetRepo { get; set; } + public string TargetApiUrl { get; set; } + public string GhesApiUrl { get; set; } + public bool NoSslVerify { get; set; } + public bool DryRun { get; set; } + [Secret] + public string GithubSourcePat { get; set; } + [Secret] + public string GithubTargetPat { get; set; } + + public override void Validate(OctoLogger log) + { + if (SourceRepo.HasValue() && TargetRepo.IsNullOrWhiteSpace()) + { + TargetRepo = SourceRepo; + log?.LogInformation("Since target-repo is not provided, source-repo value will be used for target-repo."); + } + } +} diff --git a/src/gei/Commands/MigrateDependabotAlerts/MigrateDependabotAlertsCommandHandler.cs b/src/gei/Commands/MigrateDependabotAlerts/MigrateDependabotAlertsCommandHandler.cs new file mode 100644 index 000000000..2950dc5b1 --- /dev/null +++ b/src/gei/Commands/MigrateDependabotAlerts/MigrateDependabotAlertsCommandHandler.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading.Tasks; +using OctoshiftCLI.Commands; +using OctoshiftCLI.Services; + +namespace OctoshiftCLI.GithubEnterpriseImporter.Commands.MigrateDependabotAlerts; + +public class MigrateDependabotAlertsCommandHandler : ICommandHandler +{ + private readonly OctoLogger _log; + private readonly DependabotAlertService _dependabotAlertService; + + public MigrateDependabotAlertsCommandHandler(OctoLogger log, DependabotAlertService dependabotAlertService) + { + _log = log; + _dependabotAlertService = dependabotAlertService; + } + + public async Task Handle(MigrateDependabotAlertsCommandArgs args) + { + if (args is null) + { + throw new ArgumentNullException(nameof(args)); + } + + _log.LogInformation("Migrating Repo Dependabot Alerts..."); + + await _dependabotAlertService.MigrateDependabotAlerts( + args.SourceOrg, + args.SourceRepo, + args.TargetOrg, + args.TargetRepo, + args.DryRun); + + if (!args.DryRun) + { + _log.LogSuccess($"Dependabot alerts successfully migrated."); + } + } +} diff --git a/src/gei/Factories/DependabotAlertServiceFactory.cs b/src/gei/Factories/DependabotAlertServiceFactory.cs new file mode 100644 index 000000000..93b942772 --- /dev/null +++ b/src/gei/Factories/DependabotAlertServiceFactory.cs @@ -0,0 +1,37 @@ +using OctoshiftCLI.Contracts; +using OctoshiftCLI.Services; + +namespace OctoshiftCLI.GithubEnterpriseImporter.Factories; + +public class DependabotAlertServiceFactory +{ + private readonly OctoLogger _octoLogger; + private readonly ISourceGithubApiFactory _sourceGithubApiFactory; + private readonly ITargetGithubApiFactory _targetGithubApiFactory; + private readonly EnvironmentVariableProvider _environmentVariableProvider; + + public DependabotAlertServiceFactory( + OctoLogger octoLogger, + ISourceGithubApiFactory sourceGithubApiFactory, + ITargetGithubApiFactory targetGithubApiFactory, + EnvironmentVariableProvider environmentVariableProvider) + { + _octoLogger = octoLogger; + _sourceGithubApiFactory = sourceGithubApiFactory; + _targetGithubApiFactory = targetGithubApiFactory; + _environmentVariableProvider = environmentVariableProvider; + } + + public virtual DependabotAlertService + Create(string sourceApi, string sourceToken, string targetApi, string targetToken, bool sourceApiNoSsl = false) + { + sourceToken ??= _environmentVariableProvider.SourceGithubPersonalAccessToken(); + targetToken ??= _environmentVariableProvider.TargetGithubPersonalAccessToken(); + + var sourceGithubApi = sourceApiNoSsl + ? _sourceGithubApiFactory.CreateClientNoSsl(sourceApi, sourceToken) + : _sourceGithubApiFactory.Create(sourceApi, sourceToken); + + return new(sourceGithubApi, _targetGithubApiFactory.Create(targetApi, targetToken), _octoLogger); + } +} diff --git a/src/gei/Program.cs b/src/gei/Program.cs index 41bb46321..9ee6c006f 100644 --- a/src/gei/Program.cs +++ b/src/gei/Program.cs @@ -44,6 +44,7 @@ public static async Task Main(string[] args) .AddSingleton(sp => sp.GetRequiredService()) .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddHttpClient("NoSSL")