Skip to content
Draft
23 changes: 23 additions & 0 deletions src/Octoshift/DependabotAlertState.cs
Original file line number Diff line number Diff line change
@@ -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;
}
41 changes: 41 additions & 0 deletions src/Octoshift/Models/DependabotAlert.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
106 changes: 106 additions & 0 deletions src/Octoshift/Services/DependabotAlertService.cs
Original file line number Diff line number Diff line change
@@ -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<DependabotAlert> 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);
}
}
77 changes: 77 additions & 0 deletions src/Octoshift/Services/GithubApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,39 @@ public virtual async Task<IEnumerable<CodeScanningAlertInstance>> GetCodeScannin
.ToListAsync();
}

public virtual async Task<IEnumerable<DependabotAlert>> 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<string> GetEnterpriseServerVersion()
{
var url = $"{_apiUrl}/meta";
Expand Down Expand Up @@ -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<string>("dismissed_reason"),
DismissedComment = dependabotAlert.Value<string>("dismissed_comment"),
DismissedAt = dependabotAlert.Value<string>("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"]
};
}
Loading
Loading