-
-
Notifications
You must be signed in to change notification settings - Fork 4
feat: Retry for security analysis #121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| using Microsoft.Extensions.Configuration; | ||
| using Microsoft.Extensions.Logging; | ||
| using Octokit; | ||
| using PatchPanda.Units.Helpers; | ||
| using PatchPanda.Web; | ||
| using PatchPanda.Web.Db; | ||
| using PatchPanda.Web.Services; | ||
|
|
||
| namespace PatchPanda.Units.Services; | ||
|
|
||
| public class VersionServiceTests | ||
| { | ||
| private class TestableVersionService : VersionService | ||
| { | ||
| public Mock<IGitHubClient> MockGitHubClient { get; } = new(); | ||
|
|
||
| public TestableVersionService( | ||
| ILogger<VersionService> logger, | ||
| IConfiguration configuration, | ||
| IDbContextFactory<DataContext> dbContextFactory, | ||
| IAiService aiService | ||
| ) : base(logger, configuration, dbContextFactory, aiService) | ||
| { | ||
| } | ||
|
|
||
| protected override IGitHubClient GetClient() | ||
| { | ||
| return MockGitHubClient.Object; | ||
| } | ||
| } | ||
|
|
||
| private readonly Mock<ILogger<VersionService>> _logger = new(); | ||
| private readonly Mock<IConfiguration> _configuration = new(); | ||
| private readonly Mock<IAiService> _aiService = new(); | ||
| private readonly IDbContextFactory<DataContext> _dbContextFactory = Helper.CreateInMemoryFactory(); | ||
|
|
||
| [Fact] | ||
| public async Task SecurityCheck_Retries_On_Failure() | ||
| { | ||
| // Arrange | ||
| var service = new TestableVersionService( | ||
| _logger.Object, | ||
| _configuration.Object, | ||
| _dbContextFactory, | ||
| _aiService.Object | ||
| ); | ||
|
|
||
| using var db = _dbContextFactory.CreateDbContext(); | ||
|
|
||
| // Setup App Settings | ||
| db.AppSettings.Add(new AppSetting { Key = Constants.SettingsKeys.SECURITY_SCANNING_ENABLED, Value = "true" }); | ||
| await db.SaveChangesAsync(); | ||
|
|
||
| var stack = Helper.GetTestStack(); | ||
| var app = stack.Apps[0]; | ||
| app.GitHubRepo = new Tuple<string, string>(TestData.GITHUB_OWNER, TestData.GITHUB_REPO); | ||
| app.NewerVersions.Clear(); | ||
|
|
||
| db.Containers.Add(app); | ||
| await db.SaveChangesAsync(); | ||
|
|
||
| // Setup AI Service | ||
| _aiService.Setup(x => x.IsInitialized()).Returns(true); | ||
|
|
||
| // Fail twice, then succeed | ||
| _aiService.SetupSequence(x => x.AnalyzeDiff(It.IsAny<string>())) | ||
| .ThrowsAsync(new Exception(TestData.AI_ERROR)) | ||
| .ThrowsAsync(new Exception(TestData.AI_ERROR)) | ||
| .ReturnsAsync(new SecurityAnalysisResult { Analysis = TestData.SAFE_ANALYSIS, IsSuspectedMalicious = false }); | ||
|
|
||
| // Setup GitHub Client | ||
| var mockRepo = new Mock<IRepositoriesClient>(); | ||
| var mockRelease = new Mock<IReleasesClient>(); | ||
| var mockCommit = new Mock<IRepositoryCommitsClient>(); | ||
|
|
||
| service.MockGitHubClient.Setup(x => x.Repository).Returns(mockRepo.Object); | ||
| mockRepo.Setup(x => x.Release).Returns(mockRelease.Object); | ||
| mockRepo.Setup(x => x.Commit).Returns(mockCommit.Object); | ||
|
|
||
| var releaseNew = new Release( | ||
| TestData.DUMMY_URL, TestData.DUMMY_URL, TestData.DUMMY_URL, TestData.DUMMY_URL, 1, "nodeId", TestData.NEW_VERSION, | ||
| "master", TestData.NEW_VERSION, TestData.BODY, false, false, DateTimeOffset.Now, | ||
| DateTimeOffset.Now, new Author(), TestData.DUMMY_URL, TestData.DUMMY_URL, null | ||
| ); | ||
|
|
||
| var releaseCurrent = new Release( | ||
| TestData.DUMMY_URL, TestData.DUMMY_URL, TestData.DUMMY_URL, TestData.DUMMY_URL, 2, "nodeId", TestData.VERSION, | ||
| "master", TestData.VERSION, TestData.BODY, false, false, DateTimeOffset.Now, | ||
| DateTimeOffset.Now, new Author(), TestData.DUMMY_URL, TestData.DUMMY_URL, null | ||
| ); | ||
|
|
||
| mockRelease.Setup(x => x.GetAll(TestData.GITHUB_OWNER, TestData.GITHUB_REPO, It.IsAny<ApiOptions>())) | ||
| .ReturnsAsync(new List<Release> { releaseNew, releaseCurrent }); | ||
|
|
||
| // Construct GitHubCommitFile with necessary patch content | ||
| // GitHubCommitFile(filename, additions, deletions, changes, status, blobUrl, contentsUrl, rawUrl, sha, patch, previousFileName) | ||
| var commitFile = new GitHubCommitFile("filename", 0, 0, 0, "status", TestData.DUMMY_URL, TestData.DUMMY_URL, TestData.DUMMY_URL, "sha", TestData.PATCH_CONTENT, null); | ||
|
|
||
| // Construct CompareResult | ||
| // CompareResult(url, htmlUrl, permalinkUrl, diffUrl, patchUrl, baseCommit, mergeBaseCommit, status, aheadBy, behindBy, totalCommits, commits, files) | ||
| var compareResult = new CompareResult( | ||
| TestData.DUMMY_URL, TestData.DUMMY_URL, TestData.DUMMY_URL, TestData.DUMMY_URL, TestData.DUMMY_URL, | ||
| null, // baseCommit | ||
| null, // mergeBaseCommit | ||
| "ahead", // status | ||
| 1, // aheadBy | ||
| 0, // behindBy | ||
| 1, // totalCommits | ||
| new List<GitHubCommit>(), // commits | ||
| new List<GitHubCommitFile> { commitFile } // files | ||
| ); | ||
|
|
||
| mockCommit.Setup(x => x.Compare(TestData.GITHUB_OWNER, TestData.GITHUB_REPO, It.IsAny<string>(), It.IsAny<string>())) | ||
| .ReturnsAsync(compareResult); | ||
|
|
||
| // Act | ||
| var result = await service.GetNewerVersions(app, []); | ||
|
|
||
| // Assert | ||
| _aiService.Verify(x => x.AnalyzeDiff(It.IsAny<string>()), Times.AtLeast(3)); | ||
| Assert.Single(result); | ||
| Assert.Equal(TestData.SAFE_ANALYSIS, result.First().SecurityAnalysis); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -35,7 +35,7 @@ IAiService aiService | |||||||||||||||||||||||||||||||||
| _aiService = aiService; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| private GitHubClient GetClient() | ||||||||||||||||||||||||||||||||||
| protected virtual IGitHubClient GetClient() | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| var client = new GitHubClient(new ProductHeaderValue("PatchPanda")); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
@@ -174,58 +174,86 @@ await db.AppSettings.FirstOrDefaultAsync(x => | |||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (securityScanningEnabled && _aiService.IsInitialized()) | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| var client = GetClient(); | ||||||||||||||||||||||||||||||||||
| // Resolve the correct tag for the current version to ensure comparison works | ||||||||||||||||||||||||||||||||||
| // We extract the semantic version portion from the app version and use it to find the source tag | ||||||||||||||||||||||||||||||||||
| var adjustedRegex = app.GitHubVersionRegex.TrimStart('^', 'v').TrimEnd('$'); | ||||||||||||||||||||||||||||||||||
| var versionMatch = Regex.Match(app.Version, adjustedRegex); | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+177
to
+180
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Safer prefix removal- var adjustedRegex = app.GitHubVersionRegex.TrimStart('^', 'v').TrimEnd('$');
+ var adjustedRegex = app.GitHubVersionRegex;
+ if (adjustedRegex.StartsWith('^'))
+ adjustedRegex = adjustedRegex[1..];
+ if (adjustedRegex.StartsWith('v'))
+ adjustedRegex = adjustedRegex[1..];
+ if (adjustedRegex.EndsWith('$'))
+ adjustedRegex = adjustedRegex[..^1];📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Resolve the correct tag for the current version to ensure comparison works | ||||||||||||||||||||||||||||||||||
| // We extract the semantic version portion from the app version and use it to find the source tag | ||||||||||||||||||||||||||||||||||
| var adjustedRegex = app.GitHubVersionRegex.TrimStart('^', 'v').TrimEnd('$'); | ||||||||||||||||||||||||||||||||||
| var versionMatch = Regex.Match(app.Version, adjustedRegex); | ||||||||||||||||||||||||||||||||||
| if (versionMatch.Success) | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| var currentRelease = allReleases.FirstOrDefault(r => | ||||||||||||||||||||||||||||||||||
| r.TagName is not null | ||||||||||||||||||||||||||||||||||
| && Regex.IsMatch(r.TagName, Regex.Escape(versionMatch.Value)) | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+184
to
+187
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Substring match can select the wrong release.
Anchor the pattern or use simple string matching: Proposed fix: anchor the escaped pattern var currentRelease = allReleases.FirstOrDefault(r =>
r.TagName is not null
- && Regex.IsMatch(r.TagName, Regex.Escape(versionMatch.Value))
+ && Regex.IsMatch(r.TagName, $@"(^|[^0-9]){Regex.Escape(versionMatch.Value)}($|[^0-9])")
);Alternatively, a simpler non-regex approach: var currentRelease = allReleases.FirstOrDefault(r =>
- r.TagName is not null
- && Regex.IsMatch(r.TagName, Regex.Escape(versionMatch.Value))
+ r.TagName is not null
+ && r.TagName.TrimStart('v') == versionMatch.Value
);📝 Committable suggestion
Suggested change
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (versionMatch.Success) | ||||||||||||||||||||||||||||||||||
| if (currentRelease != null) | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| var currentRelease = allReleases.FirstOrDefault(r => | ||||||||||||||||||||||||||||||||||
| r.TagName is not null | ||||||||||||||||||||||||||||||||||
| && Regex.IsMatch(r.TagName, Regex.Escape(versionMatch.Value)) | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| var baseTag = currentRelease.TagName; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (currentRelease != null) | ||||||||||||||||||||||||||||||||||
| for (int i = 1; i <= Constants.Limits.MAX_OLLAMA_ATTEMPTS; i++) | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| var baseTag = currentRelease.TagName; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Get the difference between the current version and the new version | ||||||||||||||||||||||||||||||||||
| var diff = await client.Repository.Commit.Compare( | ||||||||||||||||||||||||||||||||||
| repo.Item1, | ||||||||||||||||||||||||||||||||||
| repo.Item2, | ||||||||||||||||||||||||||||||||||
| baseTag, | ||||||||||||||||||||||||||||||||||
| notSeenNewVersion.VersionNumber | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| var textToAnalyze = string.Concat( | ||||||||||||||||||||||||||||||||||
| diff.Files.Select(f => f.Patch ?? "") | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| var analysis = await _aiService.AnalyzeDiff(textToAnalyze); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (analysis is not null) | ||||||||||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| var client = GetClient(); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Get the difference between the current version and the new version | ||||||||||||||||||||||||||||||||||
| var diff = await client.Repository.Commit.Compare( | ||||||||||||||||||||||||||||||||||
| repo.Item1, | ||||||||||||||||||||||||||||||||||
| repo.Item2, | ||||||||||||||||||||||||||||||||||
| baseTag, | ||||||||||||||||||||||||||||||||||
| notSeenNewVersion.VersionNumber | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| var textToAnalyze = string.Concat( | ||||||||||||||||||||||||||||||||||
| diff.Files.Select(f => f.Patch ?? "") | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+193
to
+209
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. GitHub API call is needlessly inside the retry loop, wasting rate limit. The Proposed restructuring var baseTag = currentRelease.TagName;
+ var client = GetClient();
+ var diff = await client.Repository.Commit.Compare(
+ repo.Item1,
+ repo.Item2,
+ baseTag,
+ notSeenNewVersion.VersionNumber
+ );
+ var textToAnalyze = string.Concat(
+ diff.Files.Select(f => f.Patch ?? "")
+ );
for (int i = 1; i <= Constants.Limits.MAX_OLLAMA_ATTEMPTS; i++)
{
try
{
- var client = GetClient();
-
- var diff = await client.Repository.Commit.Compare(
- repo.Item1,
- repo.Item2,
- baseTag,
- notSeenNewVersion.VersionNumber
- );
-
- var textToAnalyze = string.Concat(
- diff.Files.Select(f => f.Patch ?? "")
- );
-
var analysis = await _aiService.AnalyzeDiff(textToAnalyze);🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| var analysis = await _aiService.AnalyzeDiff(textToAnalyze); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (analysis is not null) | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| notSeenNewVersion.SecurityAnalysis = analysis.Analysis; | ||||||||||||||||||||||||||||||||||
| notSeenNewVersion.IsSuspectedMalicious = | ||||||||||||||||||||||||||||||||||
| analysis.IsSuspectedMalicious; | ||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| _logger.LogWarning( | ||||||||||||||||||||||||||||||||||
| "Attempting to perform security scan for {Repo} version {Version}, attempt {Count} out of {Max} resulted in null analysis.", | ||||||||||||||||||||||||||||||||||
| $"{repo.Item1}/{repo.Item2}", | ||||||||||||||||||||||||||||||||||
| notSeenNewVersion.VersionNumber, | ||||||||||||||||||||||||||||||||||
| i, | ||||||||||||||||||||||||||||||||||
| Constants.Limits.MAX_OLLAMA_ATTEMPTS | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| catch (Exception ex) | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| notSeenNewVersion.SecurityAnalysis = analysis.Analysis; | ||||||||||||||||||||||||||||||||||
| notSeenNewVersion.IsSuspectedMalicious = | ||||||||||||||||||||||||||||||||||
| analysis.IsSuspectedMalicious; | ||||||||||||||||||||||||||||||||||
| if (i == Constants.Limits.MAX_OLLAMA_ATTEMPTS) | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| _logger.LogError( | ||||||||||||||||||||||||||||||||||
| ex, | ||||||||||||||||||||||||||||||||||
| "Failed to perform security scan for {Repo} version {Version}", | ||||||||||||||||||||||||||||||||||
| $"{repo.Item1}/{repo.Item2}", | ||||||||||||||||||||||||||||||||||
| notSeenNewVersion.VersionNumber | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| _logger.LogWarning( | ||||||||||||||||||||||||||||||||||
| ex, | ||||||||||||||||||||||||||||||||||
| "Attempting to perform security scan for {Repo} version {Version}, attempt {Count} out of {Max} failed.", | ||||||||||||||||||||||||||||||||||
| $"{repo.Item1}/{repo.Item2}", | ||||||||||||||||||||||||||||||||||
| notSeenNewVersion.VersionNumber, | ||||||||||||||||||||||||||||||||||
| i, | ||||||||||||||||||||||||||||||||||
| Constants.Limits.MAX_OLLAMA_ATTEMPTS | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+193
to
254
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial No delay between retry attempts. Immediate retries against the AI service are likely to hit the same transient failure. Adding a small delay (e.g., exponential backoff or a fixed pause) would improve reliability. Example: add a short delay on failure catch (Exception ex)
{
if (i == Constants.Limits.MAX_OLLAMA_ATTEMPTS)
{
_logger.LogError(
ex,
"Failed to perform security scan for {Repo} version {Version}",
$"{repo.Item1}/{repo.Item2}",
notSeenNewVersion.VersionNumber
);
}
else
{
_logger.LogWarning(
ex,
"Attempting to perform security scan for {Repo} version {Version}, attempt {Count} out of {Max} failed.",
$"{repo.Item1}/{repo.Item2}",
notSeenNewVersion.VersionNumber,
i,
Constants.Limits.MAX_OLLAMA_ATTEMPTS
);
+ await Task.Delay(TimeSpan.FromSeconds(i * 2));
}
}🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| catch (Exception ex) | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| _logger.LogError( | ||||||||||||||||||||||||||||||||||
| ex, | ||||||||||||||||||||||||||||||||||
| "Failed to perform security scan for {Repo} version {Version}", | ||||||||||||||||||||||||||||||||||
| $"{repo.Item1}/{repo.Item2}", | ||||||||||||||||||||||||||||||||||
| notSeenNewVersion.VersionNumber | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (_aiService.IsInitialized()) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Potential test gap: consider testing when all retries are exhausted.
The test covers the happy path (fail twice, succeed on third). Consider adding a complementary test where all attempts fail, verifying that the result has no
SecurityAnalysisset and that the final error is logged.🤖 Prompt for AI Agents