Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,41 @@ All notable changes to the Codacy.Api project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added
- Webhook endpoint management on `IOrganizationsApi`: `ListWebhookEndpointsAsync`,
`CreateWebhookEndpointAsync` and `DeleteWebhookEndpointAsync`. The create response carries the
signing secret, which Codacy returns only once.
- `CodacyWebhook` for receivers: `VerifySignature` checks the `X-Codacy-Signature`
(`sha256=<hex>`) HMAC-SHA256 of the raw body in constant time, and `Deserialize` reads the
`quality.analysis.completed` payload into `WebhookAnalysisCompleted`.

- Every other non-deprecated operation in the official specification that the client lacked,
about 170 in all, in 23 new API modules on `ICodacyClient`: `Sbom`, `Images`, `Reports`,
`AiInventory`, `Billing`, `OrganizationSettings`, `Enterprises`, `Admin`, `Platform`,
`RepositorySettings`, `RepositoryApiTokens`, `RepositoryFiles`, `RepositoryCoverageReports`,
`Diffs`, `GatePolicies`, `Segments`, `Jira`, `Slack`, `Dast`, `RepositoryToolPatterns`,
`AnalysisActions`, `Tools` and `Metrics`. These were written from the specification and have
not been exercised against the live API.
- Methods that return a CSV report (`IReportsApi`) return a `Stream` the caller must dispose.

### Fixed
- `SearchRepositoryIgnoredIssuesAsync` and `SyncOrganizationNameAsync` called paths that are
not in the official Codacy specification. They now use `.../ignoredIssues/search` and
`.../settings/sync`.

### Deprecated
- The `repositories` query parameter on `ListOrganizationRepositoriesWithAnalysisAsync` and
`ListOrganizationPullRequestsAsync`, which Codacy has deprecated. Use
`SearchOrganizationRepositoriesWithAnalysisAsync`. C# cannot mark a parameter `[Obsolete]`,
so this is stated in the XML documentation.
- `CleanCacheAsync`: Codacy has removed `cache/clean` from its API.

### Changed
- `swagger.yaml` is now the official specification from `api.codacy.com` (still v3.1.0, but
about 3,000 lines longer than the copy it replaces).

## 4.0.0

### Fixed
Expand Down
117 changes: 117 additions & 0 deletions Codacy.Api.Test/Models/WebhookTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

namespace Codacy.Api.Test.Models;

/// <summary>
/// Tests for the webhook models and the delivery verifier, using bodies taken from the Codacy
/// webhook documentation and API reference.
/// </summary>
public class WebhookTests
{
private static JsonSerializerOptions Options => CodacyClient.JsonSerializerOptions;

private const string PullRequestBody = """
{
"event": "quality.analysis.completed",
"repository": { "name": "engine" },
"organization": { "id": 123456 },
"target": { "type": "pullRequest", "value": "464" },
"commitSha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
"status": "partial_success",
"timestamp": "2025-09-17T23:00:00Z"
}
""";

private static string Sign(string body, string secret) =>
"sha256=" + Convert.ToHexStringLower(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(body)));

[Fact]
public void Deserialize_PullRequestDelivery_ReadsEveryField()
{
var payload = CodacyWebhook.Deserialize(PullRequestBody);

payload.Event.Should().Be(CodacyWebhook.AnalysisCompletedEvent);
payload.Repository.Name.Should().Be("engine");
payload.Organization.Id.Should().Be(123456);
payload.Target.Type.Should().Be(WebhookTargetType.PullRequest);
payload.Target.Value.Should().Be("464");
payload.CommitSha.Should().Be("a1b2c3d4e5f60718293a4b5c6d7e8f9012345678");
payload.Status.Should().Be(WebhookAnalysisStatus.PartialSuccess);
payload.Timestamp.Should().Be(DateTimeOffset.Parse("2025-09-17T23:00:00Z", CultureInfo.InvariantCulture));
}

[Theory]
[InlineData("success", WebhookAnalysisStatus.Success)]
[InlineData("failure", WebhookAnalysisStatus.Failure)]
public void Deserialize_BranchDelivery_ReadsStatusAndTarget(string status, WebhookAnalysisStatus expected)
{
var body = PullRequestBody
.Replace("partial_success", status, StringComparison.Ordinal)
.Replace("pullRequest", "branch", StringComparison.Ordinal);

var payload = CodacyWebhook.Deserialize(body);

payload.Status.Should().Be(expected);
payload.Target.Type.Should().Be(WebhookTargetType.Branch);
}

[Fact]
public void WebhookEndpointCreated_ReadsSecret()
{
const string json = """
{
"id": "80f64371-e6bc-4d9b-b022-7c873cc5e39f",
"url": "https://example.com/webhooks/codacy",
"createdAt": "2020-11-09T09:10:00Z",
"secret": "3n8fVhZ2k9m1QpXeYtR7wLdCsUbGjNoA"
}
""";

var created = JsonSerializer.Deserialize<WebhookEndpointCreated>(json, Options)!;

created.Id.Should().Be(Guid.Parse("80f64371-e6bc-4d9b-b022-7c873cc5e39f"));
created.Url.Should().Be("https://example.com/webhooks/codacy");
created.Secret.Should().Be("3n8fVhZ2k9m1QpXeYtR7wLdCsUbGjNoA");
}

[Fact]
public void WebhookEndpointList_ReadsCountAndLimit()
{
const string json = """
{
"data": [ { "id": "80f64371-e6bc-4d9b-b022-7c873cc5e39f", "url": "https://example.com/webhooks/codacy", "createdAt": "2020-11-09T09:10:00Z" } ],
"count": 2,
"limit": 10
}
""";

var list = JsonSerializer.Deserialize<WebhookEndpointList>(json, Options)!;

list.Data.Should().ContainSingle();
list.Count.Should().Be(2);
list.Limit.Should().Be(10);
}

[Fact]
public void VerifySignature_ValidSignature_ReturnsTrue() =>
CodacyWebhook.VerifySignature(PullRequestBody, Sign(PullRequestBody, "s3cret"), "s3cret").Should().BeTrue();

[Fact]
public void VerifySignature_TamperedBody_ReturnsFalse() =>
CodacyWebhook.VerifySignature(PullRequestBody + " ", Sign(PullRequestBody, "s3cret"), "s3cret").Should().BeFalse();

[Fact]
public void VerifySignature_WrongSecret_ReturnsFalse() =>
CodacyWebhook.VerifySignature(PullRequestBody, Sign(PullRequestBody, "other"), "s3cret").Should().BeFalse();

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("deadbeef")]
[InlineData("sha256=not-hex")]
[InlineData("sha256=")]
public void VerifySignature_MissingOrMalformedHeader_ReturnsFalse(string? header) =>
CodacyWebhook.VerifySignature(PullRequestBody, header, "s3cret").Should().BeFalse();
}
140 changes: 139 additions & 1 deletion Codacy.Api/CodacyClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Text.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Codacy.Api.Interfaces;
using Refit;
Expand Down Expand Up @@ -64,6 +64,29 @@ public CodacyClient(CodacyClientOptions options)
Coverage = CreateApiClient<ICoverageApi>();
CodingStandards = CreateApiClient<ICodingStandardsApi>();
Security = CreateApiClient<ISecurityApi>();
Sbom = CreateApiClient<ISbomApi>();
Images = CreateApiClient<IImagesApi>();
Reports = CreateApiClient<IReportsApi>();
AiInventory = CreateApiClient<IAiInventoryApi>();
Billing = CreateApiClient<IBillingApi>();
OrganizationSettings = CreateApiClient<IOrganizationSettingsApi>();
Enterprises = CreateApiClient<IEnterprisesApi>();
Admin = CreateApiClient<IAdminApi>();
Platform = CreateApiClient<IPlatformApi>();
RepositorySettings = CreateApiClient<IRepositorySettingsApi>();
RepositoryApiTokens = CreateApiClient<IRepositoryApiTokensApi>();
RepositoryFiles = CreateApiClient<IRepositoryFilesApi>();
Diffs = CreateApiClient<IDiffsApi>();
RepositoryCoverageReports = CreateApiClient<IRepositoryCoverageReportsApi>();
GatePolicies = CreateApiClient<IGatePoliciesApi>();
Segments = CreateApiClient<ISegmentsApi>();
Jira = CreateApiClient<IJiraApi>();
Slack = CreateApiClient<ISlackApi>();
Dast = CreateApiClient<IDastApi>();
RepositoryToolPatterns = CreateApiClient<IRepositoryToolPatternsApi>();
AnalysisActions = CreateApiClient<IAnalysisActionsApi>();
Tools = CreateApiClient<IToolsApi>();
Metrics = CreateApiClient<IMetricsApi>();
}

/// <summary>
Expand Down Expand Up @@ -126,6 +149,121 @@ public CodacyClient(CodacyClientOptions options)
/// </summary>
public ISecurityApi Security { get; }

/// <summary>
/// Gets the Sbom API module
/// </summary>
public ISbomApi Sbom { get; }

/// <summary>
/// Gets the Images API module
/// </summary>
public IImagesApi Images { get; }

/// <summary>
/// Gets the Reports API module
/// </summary>
public IReportsApi Reports { get; }

/// <summary>
/// Gets the AiInventory API module
/// </summary>
public IAiInventoryApi AiInventory { get; }

/// <summary>
/// Gets the Billing API module
/// </summary>
public IBillingApi Billing { get; }

/// <summary>
/// Gets the OrganizationSettings API module
/// </summary>
public IOrganizationSettingsApi OrganizationSettings { get; }

/// <summary>
/// Gets the Enterprises API module
/// </summary>
public IEnterprisesApi Enterprises { get; }

/// <summary>
/// Gets the Admin API module
/// </summary>
public IAdminApi Admin { get; }

/// <summary>
/// Gets the Platform API module
/// </summary>
public IPlatformApi Platform { get; }

/// <summary>
/// Gets the RepositorySettings API module
/// </summary>
public IRepositorySettingsApi RepositorySettings { get; }

/// <summary>
/// Gets the RepositoryApiTokens API module
/// </summary>
public IRepositoryApiTokensApi RepositoryApiTokens { get; }

/// <summary>
/// Gets the RepositoryFiles API module
/// </summary>
public IRepositoryFilesApi RepositoryFiles { get; }

/// <summary>
/// Gets the Diffs API module
/// </summary>
public IDiffsApi Diffs { get; }

/// <summary>
/// Gets the RepositoryCoverageReports API module
/// </summary>
public IRepositoryCoverageReportsApi RepositoryCoverageReports { get; }

/// <summary>
/// Gets the GatePolicies API module
/// </summary>
public IGatePoliciesApi GatePolicies { get; }

/// <summary>
/// Gets the Segments API module
/// </summary>
public ISegmentsApi Segments { get; }

/// <summary>
/// Gets the Jira API module
/// </summary>
public IJiraApi Jira { get; }

/// <summary>
/// Gets the Slack API module
/// </summary>
public ISlackApi Slack { get; }

/// <summary>
/// Gets the Dast API module
/// </summary>
public IDastApi Dast { get; }

/// <summary>
/// Gets the RepositoryToolPatterns API module
/// </summary>
public IRepositoryToolPatternsApi RepositoryToolPatterns { get; }

/// <summary>
/// Gets the AnalysisActions API module
/// </summary>
public IAnalysisActionsApi AnalysisActions { get; }

/// <summary>
/// Gets the Tools API module
/// </summary>
public IToolsApi Tools { get; }

/// <summary>
/// Gets the Metrics API module
/// </summary>
public IMetricsApi Metrics { get; }

/// <summary>
/// Creates an API client using Refit
/// </summary>
Expand Down
76 changes: 76 additions & 0 deletions Codacy.Api/CodacyWebhook.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Codacy.Api.Models;

namespace Codacy.Api;

/// <summary>
/// Helpers for receiving Codacy webhook deliveries: verifying the signature and reading the
/// payload. Codacy signs the raw request body with the secret returned when the endpoint was
/// created, so verify the bytes exactly as received, before any re-serialization.
/// </summary>
public static class CodacyWebhook
{
/// <summary>Name of the header carrying the signature, <c>sha256=&lt;hex&gt;</c></summary>
public static string SignatureHeader { get; } = "X-Codacy-Signature";

/// <summary>Name of the header carrying a unique identifier per delivery attempt</summary>
public static string DeliveryHeader { get; } = "X-Codacy-Delivery";

/// <summary>The only event Codacy currently sends</summary>
public static string AnalysisCompletedEvent { get; } = "quality.analysis.completed";

private const string SignaturePrefix = "sha256=";

/// <summary>
/// Verifies a delivery's signature in constant time
/// </summary>
/// <param name="body">The raw request body, exactly as received</param>
/// <param name="signatureHeader">The value of <see cref="SignatureHeader"/>; null or malformed values fail verification</param>
/// <param name="secret">The endpoint secret from <see cref="WebhookEndpointCreated.Secret"/></param>
/// <returns>True if the signature matches</returns>
public static bool VerifySignature(ReadOnlySpan<byte> body, string? signatureHeader, string secret)
{
ArgumentException.ThrowIfNullOrEmpty(secret);

if (signatureHeader is null
|| !signatureHeader.StartsWith(SignaturePrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}

byte[] provided;
try
{
provided = Convert.FromHexString(signatureHeader.AsSpan(SignaturePrefix.Length));
}
catch (FormatException)
{
return false;
}

var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), body);
return CryptographicOperations.FixedTimeEquals(expected, provided);
}

/// <summary>
/// Verifies a delivery's signature in constant time
/// </summary>
/// <param name="body">The raw request body, exactly as received</param>
/// <param name="signatureHeader">The value of <see cref="SignatureHeader"/></param>
/// <param name="secret">The endpoint secret from <see cref="WebhookEndpointCreated.Secret"/></param>
/// <returns>True if the signature matches</returns>
public static bool VerifySignature(string body, string? signatureHeader, string secret)
=> VerifySignature(Encoding.UTF8.GetBytes(body), signatureHeader, secret);

/// <summary>
/// Reads a <c>quality.analysis.completed</c> delivery body
/// </summary>
/// <param name="body">The request body</param>
/// <returns>The payload</returns>
/// <exception cref="JsonException">The body is not a valid payload</exception>
public static WebhookAnalysisCompleted Deserialize(string body)
=> JsonSerializer.Deserialize<WebhookAnalysisCompleted>(body, CodacyClient.JsonSerializerOptions)
?? throw new JsonException("The webhook body was null.");
}
Loading
Loading