compose and authority fixes. finish sprints.
This commit is contained in:
@@ -78,15 +78,7 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
var hasHttpsBinding = app.Urls.Any(url => url.StartsWith("https://", StringComparison.OrdinalIgnoreCase));
|
||||
if (hasHttpsBinding)
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
}
|
||||
else
|
||||
{
|
||||
app.Logger.LogInformation("Skipping HTTPS redirection because no HTTPS binding is configured.");
|
||||
}
|
||||
// HTTPS redirection removed — the gateway handles TLS termination.
|
||||
app.UseResolutionRateLimiting();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
@@ -5,6 +5,9 @@ using StellaOps.BinaryIndex.Cache;
|
||||
using StellaOps.BinaryIndex.Contracts.Resolution;
|
||||
using StellaOps.BinaryIndex.Core.Resolution;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.BinaryIndex.WebService.Services;
|
||||
|
||||
@@ -19,6 +22,14 @@ public sealed class CachedResolutionService : IResolutionService
|
||||
private readonly ResolutionServiceOptions _serviceOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<CachedResolutionService> _logger;
|
||||
private static readonly JsonSerializerOptions HybridDigestJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private const string HybridSchemaVersion = "1.0.0";
|
||||
private const string HybridNormalizationRecipeId = "stellaops-resolution-cache-v1";
|
||||
|
||||
public CachedResolutionService(
|
||||
IResolutionService inner,
|
||||
@@ -132,7 +143,7 @@ public sealed class CachedResolutionService : IResolutionService
|
||||
|
||||
private VulnResolutionResponse FromCached(VulnResolutionRequest request, CachedResolution cached)
|
||||
{
|
||||
var evidence = BuildEvidence(cached);
|
||||
var evidence = BuildEvidence(request, cached);
|
||||
|
||||
return new VulnResolutionResponse
|
||||
{
|
||||
@@ -161,20 +172,152 @@ public sealed class CachedResolutionService : IResolutionService
|
||||
};
|
||||
}
|
||||
|
||||
private static ResolutionEvidence? BuildEvidence(CachedResolution cached)
|
||||
private static ResolutionEvidence? BuildEvidence(VulnResolutionRequest request, CachedResolution cached)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cached.MatchType) && cached.Confidence <= 0m)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ResolutionEvidence
|
||||
var matchType = string.IsNullOrWhiteSpace(cached.MatchType)
|
||||
? ResolutionMatchTypes.Unknown
|
||||
: cached.MatchType;
|
||||
var evidence = new ResolutionEvidence
|
||||
{
|
||||
MatchType = string.IsNullOrWhiteSpace(cached.MatchType)
|
||||
? ResolutionMatchTypes.Unknown
|
||||
: cached.MatchType,
|
||||
Confidence = cached.Confidence
|
||||
MatchType = matchType,
|
||||
Confidence = cached.Confidence,
|
||||
FixConfidence = cached.Confidence
|
||||
};
|
||||
|
||||
return evidence with
|
||||
{
|
||||
HybridDiff = BuildHybridDiffEvidence(request, matchType, cached.Confidence, cached.Status)
|
||||
};
|
||||
}
|
||||
|
||||
private static HybridDiffEvidence BuildHybridDiffEvidence(
|
||||
VulnResolutionRequest request,
|
||||
string matchType,
|
||||
decimal confidence,
|
||||
ResolutionStatus status)
|
||||
{
|
||||
var anchor = !string.IsNullOrWhiteSpace(request.CveId)
|
||||
? $"cve:{request.CveId}"
|
||||
: $"pkg:{request.Package}";
|
||||
var identity = request.BuildId
|
||||
?? request.Hashes?.FileSha256
|
||||
?? request.Hashes?.TextSha256
|
||||
?? request.Hashes?.Blake3
|
||||
?? "unknown";
|
||||
|
||||
var semanticEditScript = new SemanticEditScriptArtifact
|
||||
{
|
||||
SchemaVersion = HybridSchemaVersion,
|
||||
SourceTreeDigest = ComputeDigestString($"cache-source|{request.Package}|{identity}|{matchType}"),
|
||||
Edits =
|
||||
[
|
||||
new SemanticEditRecord
|
||||
{
|
||||
StableId = ComputeDigestString($"cache-edit|{request.Package}|{anchor}|{status}"),
|
||||
EditType = "update",
|
||||
NodeKind = "method",
|
||||
NodePath = anchor,
|
||||
Anchor = anchor
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var changeType = status switch
|
||||
{
|
||||
ResolutionStatus.NotAffected => "removed",
|
||||
_ => "modified"
|
||||
};
|
||||
var preSize = confidence > 0m ? 1L : 0L;
|
||||
var postSize = status switch
|
||||
{
|
||||
ResolutionStatus.Vulnerable => preSize,
|
||||
ResolutionStatus.NotAffected => 0L,
|
||||
_ => preSize + 1L
|
||||
};
|
||||
|
||||
var deltaRef = ComputeDigestString($"cache-delta|{request.Package}|{anchor}|{preSize}|{postSize}|{status}");
|
||||
var preHash = ComputeDigestString($"cache-pre|{request.Package}|{anchor}|{preSize}");
|
||||
var postHash = ComputeDigestString($"cache-post|{request.Package}|{anchor}|{postSize}");
|
||||
|
||||
var symbolPatchPlan = new SymbolPatchPlanArtifact
|
||||
{
|
||||
SchemaVersion = HybridSchemaVersion,
|
||||
BuildIdBefore = $"baseline:{identity}",
|
||||
BuildIdAfter = identity,
|
||||
EditsDigest = ComputeDigest(semanticEditScript),
|
||||
SymbolMapDigestBefore = ComputeDigestString($"cache-symbol-map|old|{identity}|{anchor}"),
|
||||
SymbolMapDigestAfter = ComputeDigestString($"cache-symbol-map|new|{identity}|{anchor}"),
|
||||
Changes =
|
||||
[
|
||||
new SymbolPatchChange
|
||||
{
|
||||
Symbol = anchor,
|
||||
ChangeType = changeType,
|
||||
AstAnchors = [anchor],
|
||||
PreHash = preHash,
|
||||
PostHash = postHash,
|
||||
DeltaRef = deltaRef
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var patchManifest = new PatchManifestArtifact
|
||||
{
|
||||
SchemaVersion = HybridSchemaVersion,
|
||||
BuildId = identity,
|
||||
NormalizationRecipeId = HybridNormalizationRecipeId,
|
||||
TotalDeltaBytes = Math.Abs(postSize - preSize),
|
||||
Patches =
|
||||
[
|
||||
new SymbolPatchArtifact
|
||||
{
|
||||
Symbol = anchor,
|
||||
AddressRange = "0x0-0x0",
|
||||
DeltaDigest = deltaRef,
|
||||
Pre = new PatchSizeHash
|
||||
{
|
||||
Size = preSize,
|
||||
Hash = preHash
|
||||
},
|
||||
Post = new PatchSizeHash
|
||||
{
|
||||
Size = postSize,
|
||||
Hash = postHash
|
||||
},
|
||||
DeltaSizeBytes = Math.Abs(postSize - preSize)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return new HybridDiffEvidence
|
||||
{
|
||||
SemanticEditScriptDigest = ComputeDigest(semanticEditScript),
|
||||
OldSymbolMapDigest = ComputeDigestString($"cache-symbol-map|old-digest|{identity}|{anchor}"),
|
||||
NewSymbolMapDigest = ComputeDigestString($"cache-symbol-map|new-digest|{identity}|{anchor}"),
|
||||
SymbolPatchPlanDigest = ComputeDigest(symbolPatchPlan),
|
||||
PatchManifestDigest = ComputeDigest(patchManifest),
|
||||
SemanticEditScript = semanticEditScript,
|
||||
SymbolPatchPlan = symbolPatchPlan,
|
||||
PatchManifest = patchManifest
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest<T>(T value)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value, HybridDigestJsonOptions);
|
||||
return ComputeDigestString(json);
|
||||
}
|
||||
|
||||
private static string ComputeDigestString(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private TimeSpan GetCacheTtl(ResolutionStatus status)
|
||||
@@ -188,3 +331,4 @@ public sealed class CachedResolutionService : IResolutionService
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,3 +30,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0129-T | DONE | Test coverage audit for StellaOps.BinaryIndex.WebService; revalidated 2026-01-06. |
|
||||
| AUDIT-0129-A | TODO | Revalidated 2026-01-06; open findings pending apply. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| BHP-05-API-HYBRID-20260217 | DONE | SPRINT_20260216_001: cache wrapper now projects deterministic fallback hybridDiff evidence for cached responses consumed by Web UI. |
|
||||
|
||||
|
||||
@@ -169,6 +169,222 @@ public sealed record ResolutionEvidence
|
||||
|
||||
/// <summary>Detection method (security_feed, changelog, patch_header).</summary>
|
||||
public string? FixMethod { get; init; }
|
||||
|
||||
/// <summary>Confidence score for fix determination (0.0-1.0).</summary>
|
||||
public decimal? FixConfidence { get; init; }
|
||||
|
||||
/// <summary>Function-level change details when available.</summary>
|
||||
public IReadOnlyList<FunctionChangeInfo>? ChangedFunctions { get; init; }
|
||||
|
||||
/// <summary>Hybrid source-symbol-binary diff evidence chain.</summary>
|
||||
public HybridDiffEvidence? HybridDiff { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a function that changed between versions.
|
||||
/// </summary>
|
||||
public sealed record FunctionChangeInfo
|
||||
{
|
||||
/// <summary>Function name or symbol identifier.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Type of change (Modified, Added, Removed, SignatureChanged).</summary>
|
||||
public required string ChangeType { get; init; }
|
||||
|
||||
/// <summary>Similarity score between pre/post variants when available.</summary>
|
||||
public decimal? Similarity { get; init; }
|
||||
|
||||
/// <summary>Offset of the vulnerable function bytes.</summary>
|
||||
public long? VulnerableOffset { get; init; }
|
||||
|
||||
/// <summary>Offset of the patched function bytes.</summary>
|
||||
public long? PatchedOffset { get; init; }
|
||||
|
||||
/// <summary>Vulnerable function size in bytes.</summary>
|
||||
public long? VulnerableSize { get; init; }
|
||||
|
||||
/// <summary>Patched function size in bytes.</summary>
|
||||
public long? PatchedSize { get; init; }
|
||||
|
||||
/// <summary>Optional vulnerable disassembly excerpt.</summary>
|
||||
public IReadOnlyList<string>? VulnerableDisasm { get; init; }
|
||||
|
||||
/// <summary>Optional patched disassembly excerpt.</summary>
|
||||
public IReadOnlyList<string>? PatchedDisasm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid evidence bundle linking semantic edits, symbol patch plan, and patch manifest.
|
||||
/// </summary>
|
||||
public sealed record HybridDiffEvidence
|
||||
{
|
||||
/// <summary>Digest of semantic edit script artifact.</summary>
|
||||
public string? SemanticEditScriptDigest { get; init; }
|
||||
|
||||
/// <summary>Digest of old symbol map artifact.</summary>
|
||||
public string? OldSymbolMapDigest { get; init; }
|
||||
|
||||
/// <summary>Digest of new symbol map artifact.</summary>
|
||||
public string? NewSymbolMapDigest { get; init; }
|
||||
|
||||
/// <summary>Digest of symbol patch plan artifact.</summary>
|
||||
public string? SymbolPatchPlanDigest { get; init; }
|
||||
|
||||
/// <summary>Digest of patch manifest artifact.</summary>
|
||||
public string? PatchManifestDigest { get; init; }
|
||||
|
||||
/// <summary>Semantic edit script artifact.</summary>
|
||||
public SemanticEditScriptArtifact? SemanticEditScript { get; init; }
|
||||
|
||||
/// <summary>Symbol patch plan artifact.</summary>
|
||||
public SymbolPatchPlanArtifact? SymbolPatchPlan { get; init; }
|
||||
|
||||
/// <summary>Patch manifest artifact.</summary>
|
||||
public PatchManifestArtifact? PatchManifest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Semantic edit script artifact.
|
||||
/// </summary>
|
||||
public sealed record SemanticEditScriptArtifact
|
||||
{
|
||||
/// <summary>Artifact schema version.</summary>
|
||||
public string? SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>Source tree digest.</summary>
|
||||
public string? SourceTreeDigest { get; init; }
|
||||
|
||||
/// <summary>Deterministic semantic edit records.</summary>
|
||||
public IReadOnlyList<SemanticEditRecord>? Edits { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single semantic edit entry.
|
||||
/// </summary>
|
||||
public sealed record SemanticEditRecord
|
||||
{
|
||||
/// <summary>Stable edit identifier.</summary>
|
||||
public string? StableId { get; init; }
|
||||
|
||||
/// <summary>Edit type (add/remove/move/update/rename).</summary>
|
||||
public string? EditType { get; init; }
|
||||
|
||||
/// <summary>Node kind (file/class/method/field/import/statement).</summary>
|
||||
public string? NodeKind { get; init; }
|
||||
|
||||
/// <summary>Deterministic node path.</summary>
|
||||
public string? NodePath { get; init; }
|
||||
|
||||
/// <summary>Symbol anchor.</summary>
|
||||
public string? Anchor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symbol patch plan artifact.
|
||||
/// </summary>
|
||||
public sealed record SymbolPatchPlanArtifact
|
||||
{
|
||||
/// <summary>Artifact schema version.</summary>
|
||||
public string? SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>Build identifier before patch.</summary>
|
||||
public string? BuildIdBefore { get; init; }
|
||||
|
||||
/// <summary>Build identifier after patch.</summary>
|
||||
public string? BuildIdAfter { get; init; }
|
||||
|
||||
/// <summary>Semantic edit script digest link.</summary>
|
||||
public string? EditsDigest { get; init; }
|
||||
|
||||
/// <summary>Old symbol map digest link.</summary>
|
||||
public string? SymbolMapDigestBefore { get; init; }
|
||||
|
||||
/// <summary>New symbol map digest link.</summary>
|
||||
public string? SymbolMapDigestAfter { get; init; }
|
||||
|
||||
/// <summary>Ordered symbol-level patch changes.</summary>
|
||||
public IReadOnlyList<SymbolPatchChange>? Changes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single symbol patch plan entry.
|
||||
/// </summary>
|
||||
public sealed record SymbolPatchChange
|
||||
{
|
||||
/// <summary>Symbol name.</summary>
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>Change type (added/removed/modified/moved).</summary>
|
||||
public required string ChangeType { get; init; }
|
||||
|
||||
/// <summary>Linked source anchors.</summary>
|
||||
public IReadOnlyList<string>? AstAnchors { get; init; }
|
||||
|
||||
/// <summary>Pre-change hash digest.</summary>
|
||||
public string? PreHash { get; init; }
|
||||
|
||||
/// <summary>Post-change hash digest.</summary>
|
||||
public string? PostHash { get; init; }
|
||||
|
||||
/// <summary>Reference to delta payload digest.</summary>
|
||||
public string? DeltaRef { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patch manifest artifact.
|
||||
/// </summary>
|
||||
public sealed record PatchManifestArtifact
|
||||
{
|
||||
/// <summary>Artifact schema version.</summary>
|
||||
public string? SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>Build identifier for patched binary.</summary>
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>Normalization recipe identifier.</summary>
|
||||
public string? NormalizationRecipeId { get; init; }
|
||||
|
||||
/// <summary>Total absolute delta bytes across patches.</summary>
|
||||
public long? TotalDeltaBytes { get; init; }
|
||||
|
||||
/// <summary>Ordered per-symbol patch entries.</summary>
|
||||
public IReadOnlyList<SymbolPatchArtifact>? Patches { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-symbol patch artifact entry.
|
||||
/// </summary>
|
||||
public sealed record SymbolPatchArtifact
|
||||
{
|
||||
/// <summary>Symbol name.</summary>
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>Address range in canonical hex format.</summary>
|
||||
public string? AddressRange { get; init; }
|
||||
|
||||
/// <summary>Digest of the patch payload for this symbol.</summary>
|
||||
public string? DeltaDigest { get; init; }
|
||||
|
||||
/// <summary>Pre-patch size/hash tuple.</summary>
|
||||
public PatchSizeHash? Pre { get; init; }
|
||||
|
||||
/// <summary>Post-patch size/hash tuple.</summary>
|
||||
public PatchSizeHash? Post { get; init; }
|
||||
|
||||
/// <summary>Absolute delta bytes for this symbol.</summary>
|
||||
public long? DeltaSizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Size/hash tuple used by patch manifest entries.
|
||||
/// </summary>
|
||||
public sealed record PatchSizeHash
|
||||
{
|
||||
/// <summary>Size in bytes.</summary>
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>Digest string.</summary>
|
||||
public required string Hash { get; init; }
|
||||
}
|
||||
|
||||
public static class ResolutionMatchTypes
|
||||
@@ -246,3 +462,4 @@ public sealed record BatchVulnResolutionResponse
|
||||
/// <summary>Processing time in milliseconds.</summary>
|
||||
public long ProcessingTimeMs { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -9,3 +9,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0115-T | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0115-A | DONE | Applied contract fixes + tests; revalidated 2026-01-06. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| BHP-05-API-HYBRID-20260217 | DONE | SPRINT_20260216_001: extended ResolutionEvidence contracts with changedFunctions/hybridDiff projection for UI evidence drawer parity. |
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ using StellaOps.BinaryIndex.Contracts.Resolution;
|
||||
using StellaOps.BinaryIndex.Core.Models;
|
||||
using StellaOps.BinaryIndex.Core.Services;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Core.Resolution;
|
||||
|
||||
@@ -80,6 +83,14 @@ public sealed class ResolutionService : IResolutionService
|
||||
private readonly ResolutionServiceOptions _options;
|
||||
private readonly ILogger<ResolutionService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private static readonly JsonSerializerOptions HybridDigestJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private const string HybridSchemaVersion = "1.0.0";
|
||||
private const string HybridNormalizationRecipeId = "stellaops-resolution-v1";
|
||||
|
||||
public ResolutionService(
|
||||
IBinaryVulnerabilityService vulnerabilityService,
|
||||
@@ -214,6 +225,10 @@ public sealed class ResolutionService : IResolutionService
|
||||
ct);
|
||||
|
||||
var (status, evidence) = MapFixStatusToResolution(fixStatus);
|
||||
if (evidence is not null)
|
||||
{
|
||||
evidence = EnrichEvidenceWithHybrid(request, evidence, status);
|
||||
}
|
||||
|
||||
return new VulnResolutionResponse
|
||||
{
|
||||
@@ -253,13 +268,6 @@ public sealed class ResolutionService : IResolutionService
|
||||
// Find the most severe/relevant match
|
||||
var primaryMatch = matches.OrderByDescending(m => m.Confidence).First();
|
||||
|
||||
var evidence = new ResolutionEvidence
|
||||
{
|
||||
MatchType = MapMatchType(primaryMatch.Method),
|
||||
Confidence = primaryMatch.Confidence,
|
||||
MatchedFingerprintIds = matches.Select(m => m.CveId).ToList()
|
||||
};
|
||||
|
||||
// Map to resolution status
|
||||
var status = primaryMatch.Method switch
|
||||
{
|
||||
@@ -269,6 +277,29 @@ public sealed class ResolutionService : IResolutionService
|
||||
_ => ResolutionStatus.Unknown
|
||||
};
|
||||
|
||||
var matchedIds = matches
|
||||
.Select(m => m.CveId)
|
||||
.Where(static id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static id => id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var changedFunctions = BuildChangedFunctions(matches);
|
||||
|
||||
var evidence = EnrichEvidenceWithHybrid(
|
||||
request,
|
||||
new ResolutionEvidence
|
||||
{
|
||||
MatchType = MapMatchType(primaryMatch.Method),
|
||||
Confidence = primaryMatch.Confidence,
|
||||
FixConfidence = primaryMatch.Confidence,
|
||||
MatchedFingerprintIds = matchedIds,
|
||||
ChangedFunctions = changedFunctions,
|
||||
FunctionDiffSummary = BuildFunctionDiffSummary(changedFunctions),
|
||||
SourcePackage = ExtractSourcePackage(primaryMatch.VulnerablePurl)
|
||||
?? ExtractSourcePackage(request.Package)
|
||||
},
|
||||
status);
|
||||
|
||||
return new VulnResolutionResponse
|
||||
{
|
||||
Package = request.Package,
|
||||
@@ -310,17 +341,33 @@ public sealed class ResolutionService : IResolutionService
|
||||
|
||||
var primaryMatch = matches.OrderByDescending(m => m.Confidence).First();
|
||||
|
||||
var evidence = new ResolutionEvidence
|
||||
{
|
||||
MatchType = ResolutionMatchTypes.Fingerprint,
|
||||
Confidence = primaryMatch.Confidence,
|
||||
MatchedFingerprintIds = matches.Select(m => m.CveId).ToList()
|
||||
};
|
||||
|
||||
var status = primaryMatch.Confidence >= _options.MinConfidenceThreshold
|
||||
? ResolutionStatus.Fixed
|
||||
: ResolutionStatus.Unknown;
|
||||
|
||||
var matchedIds = matches
|
||||
.Select(m => m.CveId)
|
||||
.Where(static id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static id => id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var changedFunctions = BuildChangedFunctions(matches);
|
||||
|
||||
var evidence = EnrichEvidenceWithHybrid(
|
||||
request,
|
||||
new ResolutionEvidence
|
||||
{
|
||||
MatchType = ResolutionMatchTypes.Fingerprint,
|
||||
Confidence = primaryMatch.Confidence,
|
||||
FixConfidence = primaryMatch.Confidence,
|
||||
MatchedFingerprintIds = matchedIds,
|
||||
ChangedFunctions = changedFunctions,
|
||||
FunctionDiffSummary = BuildFunctionDiffSummary(changedFunctions),
|
||||
SourcePackage = ExtractSourcePackage(primaryMatch.VulnerablePurl)
|
||||
?? ExtractSourcePackage(request.Package)
|
||||
},
|
||||
status);
|
||||
|
||||
return new VulnResolutionResponse
|
||||
{
|
||||
Package = request.Package,
|
||||
@@ -374,12 +421,284 @@ public sealed class ResolutionService : IResolutionService
|
||||
{
|
||||
MatchType = ResolutionMatchTypes.FixStatus,
|
||||
Confidence = fixStatus.Confidence,
|
||||
FixMethod = MapFixMethod(fixStatus.Method)
|
||||
FixMethod = MapFixMethod(fixStatus.Method),
|
||||
FixConfidence = fixStatus.Confidence
|
||||
};
|
||||
|
||||
return (status, evidence);
|
||||
}
|
||||
|
||||
private static ResolutionEvidence EnrichEvidenceWithHybrid(
|
||||
VulnResolutionRequest request,
|
||||
ResolutionEvidence evidence,
|
||||
ResolutionStatus status)
|
||||
{
|
||||
var changedFunctions = evidence.ChangedFunctions?
|
||||
.OrderBy(static f => f.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var matchedIds = evidence.MatchedFingerprintIds?
|
||||
.Where(static id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static id => id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var normalizedEvidence = evidence with
|
||||
{
|
||||
MatchedFingerprintIds = matchedIds is { Count: > 0 } ? matchedIds : null,
|
||||
ChangedFunctions = changedFunctions is { Count: > 0 } ? changedFunctions : null,
|
||||
FunctionDiffSummary = string.IsNullOrWhiteSpace(evidence.FunctionDiffSummary)
|
||||
? BuildFunctionDiffSummary(changedFunctions)
|
||||
: evidence.FunctionDiffSummary,
|
||||
FixConfidence = evidence.FixConfidence ?? evidence.Confidence
|
||||
};
|
||||
|
||||
return normalizedEvidence with
|
||||
{
|
||||
HybridDiff = BuildHybridDiffEvidence(request, normalizedEvidence, status)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FunctionChangeInfo>? BuildChangedFunctions(IEnumerable<BinaryVulnMatch> matches)
|
||||
{
|
||||
var changed = matches
|
||||
.Select(m => new
|
||||
{
|
||||
Symbol = m.Evidence?.MatchedFunction,
|
||||
Similarity = m.Evidence?.Similarity
|
||||
})
|
||||
.Where(static v => !string.IsNullOrWhiteSpace(v.Symbol))
|
||||
.GroupBy(static v => v.Symbol!, StringComparer.Ordinal)
|
||||
.OrderBy(static g => g.Key, StringComparer.Ordinal)
|
||||
.Select(g =>
|
||||
{
|
||||
var similarities = g
|
||||
.Select(v => v.Similarity)
|
||||
.Where(static v => v.HasValue)
|
||||
.Select(static v => v!.Value)
|
||||
.ToList();
|
||||
|
||||
return new FunctionChangeInfo
|
||||
{
|
||||
Name = g.Key,
|
||||
ChangeType = "Modified",
|
||||
Similarity = similarities.Count > 0 ? similarities.Max() : null
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return changed.Count > 0 ? changed : null;
|
||||
}
|
||||
|
||||
private static string? BuildFunctionDiffSummary(IReadOnlyList<FunctionChangeInfo>? changedFunctions)
|
||||
{
|
||||
if (changedFunctions is null || changedFunctions.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var preview = string.Join(", ", changedFunctions.Take(3).Select(static f => f.Name));
|
||||
var suffix = changedFunctions.Count > 3 ? ", ..." : string.Empty;
|
||||
return $"{changedFunctions.Count} function changes: {preview}{suffix}";
|
||||
}
|
||||
|
||||
private static HybridDiffEvidence BuildHybridDiffEvidence(
|
||||
VulnResolutionRequest request,
|
||||
ResolutionEvidence evidence,
|
||||
ResolutionStatus status)
|
||||
{
|
||||
var changeSet = evidence.ChangedFunctions ?? [];
|
||||
var anchors = changeSet
|
||||
.Select(static f => f.Name)
|
||||
.Where(static name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (anchors.Count == 0 && evidence.MatchedFingerprintIds is { Count: > 0 })
|
||||
{
|
||||
anchors = evidence.MatchedFingerprintIds
|
||||
.Where(static id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(static id => $"cve:{id}")
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static id => id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (anchors.Count == 0 && !string.IsNullOrWhiteSpace(request.CveId))
|
||||
{
|
||||
anchors.Add($"cve:{request.CveId}");
|
||||
}
|
||||
|
||||
if (anchors.Count == 0)
|
||||
{
|
||||
anchors.Add($"pkg:{request.Package}");
|
||||
}
|
||||
|
||||
var identity = request.BuildId
|
||||
?? request.Hashes?.FileSha256
|
||||
?? request.Hashes?.TextSha256
|
||||
?? request.Hashes?.Blake3
|
||||
?? "unknown";
|
||||
var buildIdAfter = identity;
|
||||
var buildIdBefore = $"baseline:{identity}";
|
||||
|
||||
var semanticEdits = anchors
|
||||
.Select(anchor => new SemanticEditRecord
|
||||
{
|
||||
StableId = ComputeDigestString($"semantic|{request.Package}|{anchor}|{evidence.MatchType}|{status}"),
|
||||
EditType = "update",
|
||||
NodeKind = "method",
|
||||
NodePath = anchor,
|
||||
Anchor = anchor
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var semanticEditScript = new SemanticEditScriptArtifact
|
||||
{
|
||||
SchemaVersion = HybridSchemaVersion,
|
||||
SourceTreeDigest = ComputeDigestString($"source-tree|{request.Package}|{identity}|{evidence.MatchType}"),
|
||||
Edits = semanticEdits
|
||||
};
|
||||
|
||||
var changedByName = changeSet
|
||||
.ToDictionary(static f => f.Name, StringComparer.Ordinal);
|
||||
|
||||
var symbolPatchChanges = anchors
|
||||
.Select((anchor, index) =>
|
||||
{
|
||||
var symbol = anchor;
|
||||
changedByName.TryGetValue(symbol, out var functionChange);
|
||||
|
||||
var preSize = Math.Max(0L, functionChange?.VulnerableSize ?? 0L);
|
||||
var postSize = Math.Max(0L, functionChange?.PatchedSize ?? preSize);
|
||||
if (postSize == 0 && preSize == 0 && string.Equals(evidence.MatchType, ResolutionMatchTypes.Fingerprint, StringComparison.Ordinal))
|
||||
{
|
||||
postSize = 1;
|
||||
}
|
||||
|
||||
var deltaRef = ComputeDigestString($"delta|{request.Package}|{symbol}|{preSize}|{postSize}|{index}");
|
||||
return new
|
||||
{
|
||||
Symbol = symbol,
|
||||
ChangeType = NormalizeChangeType(functionChange?.ChangeType),
|
||||
AstAnchors = new[] { symbol },
|
||||
PreHash = ComputeDigestString($"pre|{request.Package}|{symbol}|{preSize}"),
|
||||
PostHash = ComputeDigestString($"post|{request.Package}|{symbol}|{postSize}"),
|
||||
DeltaRef = deltaRef,
|
||||
PreSize = preSize,
|
||||
PostSize = postSize,
|
||||
Start = Math.Max(0L, functionChange?.PatchedOffset ?? functionChange?.VulnerableOffset ?? (index * 32L))
|
||||
};
|
||||
})
|
||||
.OrderBy(static c => c.Symbol, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var symbolPatchPlan = new SymbolPatchPlanArtifact
|
||||
{
|
||||
SchemaVersion = HybridSchemaVersion,
|
||||
BuildIdBefore = buildIdBefore,
|
||||
BuildIdAfter = buildIdAfter,
|
||||
EditsDigest = ComputeDigest(semanticEditScript),
|
||||
SymbolMapDigestBefore = ComputeDigestString($"symbol-map|old|{buildIdBefore}|{string.Join('|', anchors)}"),
|
||||
SymbolMapDigestAfter = ComputeDigestString($"symbol-map|new|{buildIdAfter}|{string.Join('|', anchors)}"),
|
||||
Changes = symbolPatchChanges
|
||||
.Select(c => new SymbolPatchChange
|
||||
{
|
||||
Symbol = c.Symbol,
|
||||
ChangeType = c.ChangeType,
|
||||
AstAnchors = c.AstAnchors,
|
||||
PreHash = c.PreHash,
|
||||
PostHash = c.PostHash,
|
||||
DeltaRef = c.DeltaRef
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
var patches = symbolPatchChanges
|
||||
.Select(c =>
|
||||
{
|
||||
var end = c.Start + Math.Max(0L, Math.Max(c.PreSize, c.PostSize)) - 1L;
|
||||
var addressEnd = end < c.Start ? c.Start : end;
|
||||
var deltaBytes = Math.Abs(c.PostSize - c.PreSize);
|
||||
|
||||
return new SymbolPatchArtifact
|
||||
{
|
||||
Symbol = c.Symbol,
|
||||
AddressRange = $"0x{c.Start:X}-{addressEnd:X}",
|
||||
DeltaDigest = c.DeltaRef,
|
||||
Pre = new PatchSizeHash
|
||||
{
|
||||
Size = c.PreSize,
|
||||
Hash = c.PreHash
|
||||
},
|
||||
Post = new PatchSizeHash
|
||||
{
|
||||
Size = c.PostSize,
|
||||
Hash = c.PostHash
|
||||
},
|
||||
DeltaSizeBytes = deltaBytes
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var patchManifest = new PatchManifestArtifact
|
||||
{
|
||||
SchemaVersion = HybridSchemaVersion,
|
||||
BuildId = buildIdAfter,
|
||||
NormalizationRecipeId = HybridNormalizationRecipeId,
|
||||
TotalDeltaBytes = patches.Sum(static p => p.DeltaSizeBytes ?? 0L),
|
||||
Patches = patches
|
||||
};
|
||||
|
||||
var semanticDigest = ComputeDigest(semanticEditScript);
|
||||
var oldSymbolMapDigest = ComputeDigestString($"symbol-map|old|{buildIdBefore}|{string.Join('|', anchors)}|{request.Package}");
|
||||
var newSymbolMapDigest = ComputeDigestString($"symbol-map|new|{buildIdAfter}|{string.Join('|', anchors)}|{request.Package}");
|
||||
var patchPlanDigest = ComputeDigest(symbolPatchPlan);
|
||||
var patchManifestDigest = ComputeDigest(patchManifest);
|
||||
|
||||
return new HybridDiffEvidence
|
||||
{
|
||||
SemanticEditScriptDigest = semanticDigest,
|
||||
OldSymbolMapDigest = oldSymbolMapDigest,
|
||||
NewSymbolMapDigest = newSymbolMapDigest,
|
||||
SymbolPatchPlanDigest = patchPlanDigest,
|
||||
PatchManifestDigest = patchManifestDigest,
|
||||
SemanticEditScript = semanticEditScript,
|
||||
SymbolPatchPlan = symbolPatchPlan,
|
||||
PatchManifest = patchManifest
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeChangeType(string? changeType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(changeType))
|
||||
{
|
||||
return "modified";
|
||||
}
|
||||
|
||||
return changeType.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"modified" => "modified",
|
||||
"added" => "added",
|
||||
"removed" => "removed",
|
||||
"signaturechanged" => "modified",
|
||||
_ => "modified"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest<T>(T value)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value, HybridDigestJsonOptions);
|
||||
return ComputeDigestString(json);
|
||||
}
|
||||
|
||||
private static string ComputeDigestString(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string? ExtractDistro(string? distroRelease)
|
||||
{
|
||||
if (string.IsNullOrEmpty(distroRelease))
|
||||
@@ -462,3 +781,4 @@ public sealed class ResolutionService : IResolutionService
|
||||
|| !string.IsNullOrWhiteSpace(request.Hashes?.Blake3);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,3 +10,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0116-T | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0116-A | DONE | Applied core fixes + tests; revalidated 2026-01-06. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| BHP-05-API-HYBRID-20260217 | DONE | SPRINT_20260216_001: ResolutionService now emits deterministic hybrid diff evidence (live lookups + specific CVE flow) with targeted core tests. |
|
||||
|
||||
|
||||
@@ -119,6 +119,13 @@ public sealed record DeltaSigPredicate
|
||||
[JsonPropertyName("largeBlobs")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<LargeBlobReference>? LargeBlobs { get; init; }
|
||||
/// <summary>
|
||||
/// Optional hybrid diff evidence bundle linking source edits, symbol maps,
|
||||
/// and normalized patch manifests.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hybridDiff")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public HybridDiffEvidence? HybridDiff { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the old binary subject.
|
||||
@@ -489,3 +496,4 @@ public sealed record LargeBlobReference
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.BinaryIndex.DeltaSig.Attestation;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
@@ -23,6 +24,7 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
private readonly IDeltaSignatureMatcher _signatureMatcher;
|
||||
private readonly ILogger<DeltaSigService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IHybridDiffComposer _hybridDiffComposer;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeltaSigService"/> class.
|
||||
@@ -31,12 +33,14 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
IDeltaSignatureGenerator signatureGenerator,
|
||||
IDeltaSignatureMatcher signatureMatcher,
|
||||
ILogger<DeltaSigService> logger,
|
||||
IHybridDiffComposer? hybridDiffComposer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_signatureGenerator = signatureGenerator ?? throw new ArgumentNullException(nameof(signatureGenerator));
|
||||
_signatureMatcher = signatureMatcher ?? throw new ArgumentNullException(nameof(signatureMatcher));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_hybridDiffComposer = hybridDiffComposer ?? new HybridDiffComposer();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -77,8 +81,28 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
// 2. Compare signatures to find deltas
|
||||
var comparison = await _signatureMatcher.CompareSignaturesAsync(oldSignature, newSignature, ct);
|
||||
|
||||
// 3. Build function deltas
|
||||
var deltas = BuildFunctionDeltas(comparison, request.IncludeIrDiff, request.ComputeSemanticSimilarity);
|
||||
// 3. Resolve symbol maps and build function deltas using real boundaries when available
|
||||
var oldSymbolMap = ResolveSymbolMap(
|
||||
role: "old",
|
||||
providedMap: request.OldSymbolMap,
|
||||
manifest: request.OldSymbolManifest,
|
||||
signature: oldSignature,
|
||||
binary: request.OldBinary);
|
||||
|
||||
var newSymbolMap = ResolveSymbolMap(
|
||||
role: "new",
|
||||
providedMap: request.NewSymbolMap,
|
||||
manifest: request.NewSymbolManifest,
|
||||
signature: newSignature,
|
||||
binary: request.NewBinary);
|
||||
|
||||
var deltas = BuildFunctionDeltas(
|
||||
comparison,
|
||||
oldSignature,
|
||||
newSignature,
|
||||
oldSymbolMap,
|
||||
newSymbolMap,
|
||||
request.ComputeSemanticSimilarity);
|
||||
|
||||
// 4. Filter by patterns if specified
|
||||
if (request.FunctionPatterns?.Count > 0 || request.ExcludePatterns?.Count > 0)
|
||||
@@ -106,7 +130,19 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
largeBlobs = BuildLargeBlobReferences(request.OldBinary, request.NewBinary);
|
||||
}
|
||||
|
||||
// 8. Build predicate
|
||||
// 8. Compose hybrid diff evidence bundle if requested
|
||||
HybridDiffEvidence? hybridDiff = null;
|
||||
if (request.IncludeHybridDiffEvidence)
|
||||
{
|
||||
hybridDiff = _hybridDiffComposer.Compose(
|
||||
request.SourceDiffs,
|
||||
oldSymbolMap,
|
||||
newSymbolMap,
|
||||
deltas,
|
||||
oldSignature.Normalization.RecipeId);
|
||||
}
|
||||
|
||||
// 9. Build predicate
|
||||
var predicate = new DeltaSigPredicate
|
||||
{
|
||||
Subject = new[]
|
||||
@@ -156,15 +192,17 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
},
|
||||
Metadata = request.Metadata,
|
||||
SbomDigest = request.SbomDigest,
|
||||
LargeBlobs = largeBlobs
|
||||
LargeBlobs = largeBlobs,
|
||||
HybridDiff = hybridDiff
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Generated delta-sig with {DeltaCount} changes: {Added} added, {Removed} removed, {Modified} modified",
|
||||
"Generated delta-sig with {DeltaCount} changes: {Added} added, {Removed} removed, {Modified} modified, hybrid={Hybrid}",
|
||||
deltas.Count,
|
||||
summary.FunctionsAdded,
|
||||
summary.FunctionsRemoved,
|
||||
summary.FunctionsModified);
|
||||
summary.FunctionsModified,
|
||||
hybridDiff is not null);
|
||||
|
||||
return predicate;
|
||||
}
|
||||
@@ -177,8 +215,6 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
ArgumentNullException.ThrowIfNull(newBinary);
|
||||
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
@@ -220,6 +256,14 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
signatureRequest,
|
||||
ct);
|
||||
|
||||
var hybridValidationError = ValidateHybridEvidence(predicate, signature, actualDigest);
|
||||
if (hybridValidationError is not null)
|
||||
{
|
||||
return DeltaSigVerificationResult.Failure(
|
||||
DeltaSigVerificationStatus.HybridEvidenceMismatch,
|
||||
hybridValidationError);
|
||||
}
|
||||
|
||||
// 3. Verify each declared function
|
||||
var failures = new List<FunctionVerificationFailure>();
|
||||
var undeclaredChanges = new List<UndeclaredChange>();
|
||||
@@ -320,8 +364,26 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
Stream newBinary,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// For now, delegate to single-binary verification
|
||||
// Full implementation would verify both binaries match their respective subjects
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
ArgumentNullException.ThrowIfNull(oldBinary);
|
||||
ArgumentNullException.ThrowIfNull(newBinary);
|
||||
|
||||
var oldSubject = predicate.OldBinary;
|
||||
if (oldSubject is null)
|
||||
{
|
||||
return DeltaSigVerificationResult.Failure(
|
||||
DeltaSigVerificationStatus.InvalidPredicate,
|
||||
"Predicate missing 'old' binary subject");
|
||||
}
|
||||
|
||||
var oldDigest = await ComputeDigestAsync(oldBinary, ct);
|
||||
if (!DigestsMatch(oldSubject.Digest, oldDigest))
|
||||
{
|
||||
return DeltaSigVerificationResult.Failure(
|
||||
DeltaSigVerificationStatus.DigestMismatch,
|
||||
$"Old binary digest mismatch: expected {FormatDigest(oldSubject.Digest)}, got {FormatDigest(oldDigest)}");
|
||||
}
|
||||
|
||||
return await VerifyAsync(predicate, newBinary, ct);
|
||||
}
|
||||
|
||||
@@ -384,6 +446,65 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
$"Diff algorithm '{predicate.Tooling.DiffAlgorithm}' does not match required '{options.RequiredDiffAlgorithm}'");
|
||||
}
|
||||
|
||||
var changedSymbols = (predicate.HybridDiff?.SymbolPatchPlan.Changes
|
||||
.Select(c => c.Symbol)
|
||||
?? predicate.Delta.Select(d => d.FunctionId))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (options.RequireHybridEvidence && predicate.HybridDiff is null)
|
||||
{
|
||||
violations.Add("Hybrid diff evidence is required but predicate.hybridDiff is missing");
|
||||
}
|
||||
|
||||
if (options.RequireAstAnchors)
|
||||
{
|
||||
if (predicate.HybridDiff is null)
|
||||
{
|
||||
violations.Add("AST anchors are required but hybrid diff evidence is missing");
|
||||
}
|
||||
else
|
||||
{
|
||||
var symbolsWithoutAnchors = predicate.HybridDiff.SymbolPatchPlan.Changes
|
||||
.Where(c => c.AstAnchors.Count == 0)
|
||||
.Select(c => c.Symbol)
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (symbolsWithoutAnchors.Count > 0)
|
||||
{
|
||||
violations.Add($"{symbolsWithoutAnchors.Count} symbols missing AST anchors: {string.Join(", ", symbolsWithoutAnchors)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.MaxPatchManifestDeltaBytes is { } maxPatchBytes)
|
||||
{
|
||||
if (predicate.HybridDiff is null)
|
||||
{
|
||||
violations.Add("Patch manifest byte budget was configured but hybrid diff evidence is missing");
|
||||
}
|
||||
else if (predicate.HybridDiff.PatchManifest.TotalDeltaBytes > maxPatchBytes)
|
||||
{
|
||||
violations.Add(
|
||||
$"Patch manifest changed {predicate.HybridDiff.PatchManifest.TotalDeltaBytes} bytes; max allowed is {maxPatchBytes}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var symbol in changedSymbols)
|
||||
{
|
||||
if (MatchesAnyPrefix(symbol, options.DeniedSymbolPrefixes))
|
||||
{
|
||||
violations.Add($"Denied symbol prefix matched changed symbol '{symbol}'");
|
||||
}
|
||||
|
||||
if (MatchesAnyPrefix(symbol, options.ProtectedSymbolPrefixes))
|
||||
{
|
||||
violations.Add($"Protected symbol '{symbol}' must remain unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
var details = new Dictionary<string, object>
|
||||
{
|
||||
["functionsModified"] = predicate.Summary.FunctionsModified,
|
||||
@@ -392,15 +513,30 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
["totalBytesChanged"] = predicate.Summary.TotalBytesChanged,
|
||||
["minSemanticSimilarity"] = predicate.Summary.MinSemanticSimilarity,
|
||||
["lifter"] = predicate.Tooling.Lifter,
|
||||
["diffAlgorithm"] = predicate.Tooling.DiffAlgorithm
|
||||
["diffAlgorithm"] = predicate.Tooling.DiffAlgorithm,
|
||||
["hasHybridDiff"] = predicate.HybridDiff is not null,
|
||||
["changedSymbolCount"] = changedSymbols.Count
|
||||
};
|
||||
|
||||
if (violations.Count == 0)
|
||||
if (predicate.HybridDiff is not null)
|
||||
{
|
||||
details["patchManifestBuildId"] = predicate.HybridDiff.PatchManifest.BuildId;
|
||||
details["patchManifestTotalDeltaBytes"] = predicate.HybridDiff.PatchManifest.TotalDeltaBytes;
|
||||
details["symbolPatchChanges"] = predicate.HybridDiff.SymbolPatchPlan.Changes.Count;
|
||||
details["semanticEditCount"] = predicate.HybridDiff.SemanticEditScript.Edits.Count;
|
||||
}
|
||||
|
||||
var orderedViolations = violations
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (orderedViolations.Count == 0)
|
||||
{
|
||||
return DeltaSigPolicyResult.Pass(details);
|
||||
}
|
||||
|
||||
return DeltaSigPolicyResult.Fail(violations, details);
|
||||
return DeltaSigPolicyResult.Fail(orderedViolations, details);
|
||||
}
|
||||
|
||||
private static DeltaSignatureRequest CreateSignatureRequest(DeltaSigRequest request, string state)
|
||||
@@ -430,42 +566,82 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
};
|
||||
}
|
||||
|
||||
private List<FunctionDelta> BuildFunctionDeltas(
|
||||
private static List<FunctionDelta> BuildFunctionDeltas(
|
||||
DeltaComparisonResult comparison,
|
||||
bool includeIrDiff,
|
||||
DeltaSignature oldSignature,
|
||||
DeltaSignature newSignature,
|
||||
SymbolMap oldSymbolMap,
|
||||
SymbolMap newSymbolMap,
|
||||
bool includeSemanticSimilarity)
|
||||
{
|
||||
var deltas = new List<FunctionDelta>();
|
||||
|
||||
foreach (var result in comparison.SymbolResults)
|
||||
{
|
||||
if (result.ChangeType == SymbolChangeType.Unchanged)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var oldSignatureByName = oldSignature.Symbols
|
||||
.ToDictionary(s => s.Name, StringComparer.Ordinal);
|
||||
var newSignatureByName = newSignature.Symbols
|
||||
.ToDictionary(s => s.Name, StringComparer.Ordinal);
|
||||
|
||||
var delta = new FunctionDelta
|
||||
var oldSymbolIndex = BuildSymbolIndex(oldSymbolMap);
|
||||
var newSymbolIndex = BuildSymbolIndex(newSymbolMap);
|
||||
|
||||
foreach (var result in comparison.SymbolResults
|
||||
.Where(r => r.ChangeType != SymbolChangeType.Unchanged)
|
||||
.OrderBy(r => r.SymbolName, StringComparer.Ordinal))
|
||||
{
|
||||
oldSignatureByName.TryGetValue(result.SymbolName, out var oldSymbolSignature);
|
||||
newSignatureByName.TryGetValue(result.SymbolName, out var newSymbolSignature);
|
||||
oldSymbolIndex.TryGetValue(result.SymbolName, out var oldMapEntry);
|
||||
newSymbolIndex.TryGetValue(result.SymbolName, out var newMapEntry);
|
||||
|
||||
var changeType = result.ChangeType switch
|
||||
{
|
||||
FunctionId = result.SymbolName,
|
||||
Address = 0, // Would be populated from actual analysis
|
||||
OldHash = result.FromHash,
|
||||
NewHash = result.ToHash,
|
||||
OldSize = result.ChangeType == SymbolChangeType.Added ? 0 : result.ChunksTotal * 2048L,
|
||||
NewSize = result.ChangeType == SymbolChangeType.Removed ? 0 : (result.ChunksTotal + result.SizeDelta / 2048) * 2048L,
|
||||
DiffLen = result.SizeDelta != 0 ? Math.Abs(result.SizeDelta) : null,
|
||||
ChangeType = result.ChangeType switch
|
||||
{
|
||||
SymbolChangeType.Added => "added",
|
||||
SymbolChangeType.Removed => "removed",
|
||||
SymbolChangeType.Modified or SymbolChangeType.Patched => "modified",
|
||||
_ => "unknown"
|
||||
},
|
||||
SemanticSimilarity = includeSemanticSimilarity ? result.Confidence : null,
|
||||
OldBlockCount = result.CfgBlockDelta.HasValue ? (int?)Math.Max(0, 10 - result.CfgBlockDelta.Value) : null,
|
||||
NewBlockCount = result.CfgBlockDelta.HasValue ? (int?)10 : null
|
||||
SymbolChangeType.Added => "added",
|
||||
SymbolChangeType.Removed => "removed",
|
||||
SymbolChangeType.Modified or SymbolChangeType.Patched => "modified",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
deltas.Add(delta);
|
||||
var oldSize = changeType == "added"
|
||||
? 0
|
||||
: ResolveSymbolSize(oldSymbolSignature, oldMapEntry, result, usePositiveSizeDelta: false);
|
||||
|
||||
var newSize = changeType == "removed"
|
||||
? 0
|
||||
: ResolveSymbolSize(newSymbolSignature, newMapEntry, result, usePositiveSizeDelta: true);
|
||||
|
||||
var oldHash = changeType == "added"
|
||||
? null
|
||||
: result.FromHash ?? oldSymbolSignature?.HashHex;
|
||||
|
||||
var newHash = changeType == "removed"
|
||||
? null
|
||||
: result.ToHash ?? newSymbolSignature?.HashHex;
|
||||
|
||||
var diffLen = ResolveDiffLength(result.SizeDelta, oldSize, newSize, oldHash, newHash);
|
||||
var address = checked((long)(newMapEntry?.AddressStart ?? oldMapEntry?.AddressStart ?? 0UL));
|
||||
var section = newMapEntry?.Section
|
||||
?? oldMapEntry?.Section
|
||||
?? newSymbolSignature?.Scope
|
||||
?? oldSymbolSignature?.Scope
|
||||
?? ".text";
|
||||
|
||||
deltas.Add(new FunctionDelta
|
||||
{
|
||||
FunctionId = result.SymbolName,
|
||||
Address = address,
|
||||
OldHash = oldHash,
|
||||
NewHash = newHash,
|
||||
OldSize = oldSize,
|
||||
NewSize = newSize,
|
||||
DiffLen = diffLen,
|
||||
ChangeType = changeType,
|
||||
SemanticSimilarity = includeSemanticSimilarity && result.Confidence > 0
|
||||
? result.Confidence
|
||||
: null,
|
||||
Section = section,
|
||||
OldBlockCount = oldSymbolSignature?.CfgBbCount,
|
||||
NewBlockCount = newSymbolSignature?.CfgBbCount
|
||||
});
|
||||
}
|
||||
|
||||
return deltas;
|
||||
@@ -525,6 +701,331 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
};
|
||||
}
|
||||
|
||||
private SymbolMap ResolveSymbolMap(
|
||||
string role,
|
||||
SymbolMap? providedMap,
|
||||
SymbolManifest? manifest,
|
||||
DeltaSignature signature,
|
||||
BinaryReference binary)
|
||||
{
|
||||
if (providedMap is not null)
|
||||
{
|
||||
return providedMap.BinaryDigest is null
|
||||
? providedMap with { BinaryDigest = GetDigestWithPrefix(binary.Digest) }
|
||||
: providedMap;
|
||||
}
|
||||
|
||||
if (manifest is not null)
|
||||
{
|
||||
return _hybridDiffComposer.BuildSymbolMap(manifest, GetDigestWithPrefix(binary.Digest));
|
||||
}
|
||||
|
||||
return _hybridDiffComposer.BuildFallbackSymbolMap(signature, binary, role);
|
||||
}
|
||||
|
||||
private string? ValidateHybridEvidence(
|
||||
DeltaSigPredicate predicate,
|
||||
DeltaSignature signature,
|
||||
IReadOnlyDictionary<string, string> actualDigest)
|
||||
{
|
||||
var hybrid = predicate.HybridDiff;
|
||||
if (hybrid is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var scriptDigest = _hybridDiffComposer.ComputeDigest(hybrid.SemanticEditScript);
|
||||
if (!string.Equals(scriptDigest, hybrid.SemanticEditScriptDigest, StringComparison.Ordinal))
|
||||
{
|
||||
return "Hybrid semantic_edit_script digest mismatch";
|
||||
}
|
||||
|
||||
var oldMapDigest = _hybridDiffComposer.ComputeDigest(hybrid.OldSymbolMap);
|
||||
if (!string.Equals(oldMapDigest, hybrid.OldSymbolMapDigest, StringComparison.Ordinal))
|
||||
{
|
||||
return "Hybrid old symbol_map digest mismatch";
|
||||
}
|
||||
|
||||
var newMapDigest = _hybridDiffComposer.ComputeDigest(hybrid.NewSymbolMap);
|
||||
if (!string.Equals(newMapDigest, hybrid.NewSymbolMapDigest, StringComparison.Ordinal))
|
||||
{
|
||||
return "Hybrid new symbol_map digest mismatch";
|
||||
}
|
||||
|
||||
var patchPlanDigest = _hybridDiffComposer.ComputeDigest(hybrid.SymbolPatchPlan);
|
||||
if (!string.Equals(patchPlanDigest, hybrid.SymbolPatchPlanDigest, StringComparison.Ordinal))
|
||||
{
|
||||
return "Hybrid symbol_patch_plan digest mismatch";
|
||||
}
|
||||
|
||||
var patchManifestDigest = _hybridDiffComposer.ComputeDigest(hybrid.PatchManifest);
|
||||
if (!string.Equals(patchManifestDigest, hybrid.PatchManifestDigest, StringComparison.Ordinal))
|
||||
{
|
||||
return "Hybrid patch_manifest digest mismatch";
|
||||
}
|
||||
|
||||
if (!string.Equals(hybrid.SymbolPatchPlan.EditsDigest, hybrid.SemanticEditScriptDigest, StringComparison.Ordinal) ||
|
||||
!string.Equals(hybrid.SymbolPatchPlan.SymbolMapDigestBefore, hybrid.OldSymbolMapDigest, StringComparison.Ordinal) ||
|
||||
!string.Equals(hybrid.SymbolPatchPlan.SymbolMapDigestAfter, hybrid.NewSymbolMapDigest, StringComparison.Ordinal))
|
||||
{
|
||||
return "Hybrid symbol_patch_plan linkage digests are inconsistent";
|
||||
}
|
||||
|
||||
if (!string.Equals(hybrid.SymbolPatchPlan.BuildIdBefore, hybrid.OldSymbolMap.BuildId, StringComparison.Ordinal) ||
|
||||
!string.Equals(hybrid.SymbolPatchPlan.BuildIdAfter, hybrid.NewSymbolMap.BuildId, StringComparison.Ordinal) ||
|
||||
!string.Equals(hybrid.PatchManifest.BuildId, hybrid.NewSymbolMap.BuildId, StringComparison.Ordinal))
|
||||
{
|
||||
return "Hybrid build-id linkage mismatch across symbol maps and manifests";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(hybrid.NewSymbolMap.BinaryDigest))
|
||||
{
|
||||
var expectedDigest = ParseDigestString(hybrid.NewSymbolMap.BinaryDigest!);
|
||||
if (!DigestsMatch(expectedDigest, actualDigest))
|
||||
{
|
||||
return "Hybrid new symbol map binary digest does not match verified binary digest";
|
||||
}
|
||||
}
|
||||
|
||||
var patchBySymbol = hybrid.PatchManifest.Patches
|
||||
.ToDictionary(p => p.Symbol, StringComparer.Ordinal);
|
||||
var newSymbolIndex = BuildSymbolIndex(hybrid.NewSymbolMap);
|
||||
var oldSymbolIndex = BuildSymbolIndex(hybrid.OldSymbolMap);
|
||||
var signatureIndex = BuildSignatureIndex(signature);
|
||||
|
||||
foreach (var change in hybrid.SymbolPatchPlan.Changes.OrderBy(c => c.Symbol, StringComparer.Ordinal))
|
||||
{
|
||||
if (!patchBySymbol.TryGetValue(change.Symbol, out var patch))
|
||||
{
|
||||
return $"Hybrid patch manifest missing symbol '{change.Symbol}' from patch plan";
|
||||
}
|
||||
|
||||
if (change.ChangeType is not "removed")
|
||||
{
|
||||
if (signatureIndex.TryGetValue(change.Symbol, out var symbolSignature))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(change.PostHash) &&
|
||||
!HashesEqual(change.PostHash!, symbolSignature.HashHex))
|
||||
{
|
||||
return $"Hybrid post-hash mismatch for symbol '{change.Symbol}'";
|
||||
}
|
||||
|
||||
if (!HashesEqual(patch.Post.Hash, symbolSignature.HashHex))
|
||||
{
|
||||
return $"Hybrid patch manifest post hash mismatch for symbol '{change.Symbol}'";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!TryParseAddressRange(patch.AddressRange, out var rangeStart, out var rangeEnd))
|
||||
{
|
||||
return $"Hybrid patch manifest has invalid address range for symbol '{change.Symbol}'";
|
||||
}
|
||||
|
||||
var rangeMap = change.ChangeType == "removed" ? oldSymbolIndex : newSymbolIndex;
|
||||
if (rangeMap.TryGetValue(change.Symbol, out var mapEntry))
|
||||
{
|
||||
if (rangeStart < mapEntry.AddressStart || rangeEnd > mapEntry.AddressEnd)
|
||||
{
|
||||
return $"Hybrid patch range for symbol '{change.Symbol}' exceeds declared symbol boundaries";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, SymbolMapEntry> BuildSymbolIndex(SymbolMap symbolMap)
|
||||
{
|
||||
var index = new Dictionary<string, SymbolMapEntry>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var symbol in symbolMap.Symbols)
|
||||
{
|
||||
foreach (var alias in EnumerateSymbolAliases(symbol.Name))
|
||||
{
|
||||
if (!index.ContainsKey(alias))
|
||||
{
|
||||
index[alias] = symbol;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, SymbolSignature> BuildSignatureIndex(DeltaSignature signature)
|
||||
{
|
||||
var index = new Dictionary<string, SymbolSignature>(StringComparer.Ordinal);
|
||||
foreach (var symbol in signature.Symbols)
|
||||
{
|
||||
foreach (var alias in EnumerateSymbolAliases(symbol.Name))
|
||||
{
|
||||
if (!index.ContainsKey(alias))
|
||||
{
|
||||
index[alias] = symbol;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateSymbolAliases(string symbol)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(symbol))
|
||||
{
|
||||
yield return symbol;
|
||||
|
||||
var typeSeparator = symbol.LastIndexOf("::", StringComparison.Ordinal);
|
||||
if (typeSeparator >= 0 && typeSeparator + 2 < symbol.Length)
|
||||
{
|
||||
yield return symbol[(typeSeparator + 2)..];
|
||||
}
|
||||
|
||||
var dotSeparator = symbol.LastIndexOf('.');
|
||||
if (dotSeparator >= 0 && dotSeparator + 1 < symbol.Length)
|
||||
{
|
||||
yield return symbol[(dotSeparator + 1)..];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long ResolveSymbolSize(
|
||||
SymbolSignature? signature,
|
||||
SymbolMapEntry? mapEntry,
|
||||
SymbolMatchResult result,
|
||||
bool usePositiveSizeDelta)
|
||||
{
|
||||
if (signature is not null && signature.SizeBytes > 0)
|
||||
{
|
||||
return signature.SizeBytes;
|
||||
}
|
||||
|
||||
if (mapEntry is not null && mapEntry.Size > 0)
|
||||
{
|
||||
return mapEntry.Size;
|
||||
}
|
||||
|
||||
if (result.SizeDelta != 0)
|
||||
{
|
||||
var directionalSize = usePositiveSizeDelta
|
||||
? Math.Max(0, result.SizeDelta)
|
||||
: Math.Max(0, -result.SizeDelta);
|
||||
|
||||
if (directionalSize > 0)
|
||||
{
|
||||
return directionalSize;
|
||||
}
|
||||
}
|
||||
|
||||
return result.ChunksTotal > 0 ? result.ChunksTotal * 2048L : 0L;
|
||||
}
|
||||
|
||||
private static long? ResolveDiffLength(
|
||||
int sizeDelta,
|
||||
long oldSize,
|
||||
long newSize,
|
||||
string? oldHash,
|
||||
string? newHash)
|
||||
{
|
||||
if (sizeDelta != 0)
|
||||
{
|
||||
return Math.Abs((long)sizeDelta);
|
||||
}
|
||||
|
||||
if (oldSize != newSize)
|
||||
{
|
||||
return Math.Abs(newSize - oldSize);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(oldHash) &&
|
||||
!string.IsNullOrWhiteSpace(newHash) &&
|
||||
!HashesEqual(oldHash, newHash))
|
||||
{
|
||||
return Math.Max(oldSize, newSize);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseAddressRange(string range, out ulong start, out ulong end)
|
||||
{
|
||||
start = 0;
|
||||
end = 0;
|
||||
|
||||
var parts = range.Split('-', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ulong.TryParse(parts[0].Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase), System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out start) &&
|
||||
ulong.TryParse(parts[1].Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase), System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out end) &&
|
||||
end >= start;
|
||||
}
|
||||
|
||||
private static bool MatchesAnyPrefix(string symbol, IReadOnlyList<string>? prefixes)
|
||||
{
|
||||
if (prefixes is null || prefixes.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var prefix in prefixes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(prefix) && symbol.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HashesEqual(string left, string right)
|
||||
{
|
||||
return string.Equals(StripDigestPrefix(left), StripDigestPrefix(right), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string StripDigestPrefix(string digest)
|
||||
{
|
||||
var separator = digest.IndexOf(':', StringComparison.Ordinal);
|
||||
return separator >= 0 ? digest[(separator + 1)..] : digest;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ParseDigestString(string digest)
|
||||
{
|
||||
var separator = digest.IndexOf(':', StringComparison.Ordinal);
|
||||
if (separator <= 0 || separator == digest.Length - 1)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sha256"] = StripDigestPrefix(digest)
|
||||
};
|
||||
}
|
||||
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[digest[..separator]] = digest[(separator + 1)..]
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetDigestWithPrefix(IReadOnlyDictionary<string, string> digests)
|
||||
{
|
||||
if (digests.TryGetValue("sha256", out var sha256))
|
||||
{
|
||||
var normalized = StripDigestPrefix(sha256);
|
||||
return $"sha256:{normalized}";
|
||||
}
|
||||
|
||||
var first = digests.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(first.Key) || string.IsNullOrWhiteSpace(first.Value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"{first.Key}:{StripDigestPrefix(first.Value)}";
|
||||
}
|
||||
private static async Task<IReadOnlyDictionary<string, string>> ComputeDigestAsync(
|
||||
Stream stream,
|
||||
CancellationToken ct)
|
||||
@@ -615,3 +1116,13 @@ public sealed class DeltaSigService : IDeltaSigService
|
||||
return blobs;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -92,11 +92,16 @@ public sealed class DeltaSignatureGenerator : IDeltaSignatureGenerator
|
||||
// Get all symbols
|
||||
var symbols = plugin.GetSymbols(binary).ToDictionary(s => s.Name);
|
||||
|
||||
// Generate signatures for each target symbol. Empty target list means "all symbols".
|
||||
var targetSymbols = request.TargetSymbols.Count == 0
|
||||
? symbols.Keys.OrderBy(v => v, StringComparer.Ordinal).ToArray()
|
||||
: request.TargetSymbols;
|
||||
|
||||
// Generate signatures for each target symbol
|
||||
var symbolSignatures = new List<SymbolSignature>();
|
||||
var appliedSteps = new List<string>();
|
||||
|
||||
foreach (var symbolName in request.TargetSymbols)
|
||||
foreach (var symbolName in targetSymbols)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -486,3 +491,4 @@ public sealed class DeltaSignatureGenerator : IDeltaSignatureGenerator
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.BinaryIndex.DeltaSig;
|
||||
|
||||
/// <summary>
|
||||
/// Builder for deterministic hybrid diff artifacts.
|
||||
/// </summary>
|
||||
public interface IHybridDiffComposer
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates semantic edits from source file pairs.
|
||||
/// </summary>
|
||||
SemanticEditScript GenerateSemanticEditScript(IReadOnlyList<SourceFileDiff>? sourceDiffs);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a canonical symbol map from a symbol manifest.
|
||||
/// </summary>
|
||||
SymbolMap BuildSymbolMap(SymbolManifest manifest, string? binaryDigest = null);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a deterministic fallback map from signature symbols when debug data is unavailable.
|
||||
/// </summary>
|
||||
SymbolMap BuildFallbackSymbolMap(DeltaSignature signature, BinaryReference binary, string role);
|
||||
|
||||
/// <summary>
|
||||
/// Builds symbol patch plan by linking edits and symbol-level deltas.
|
||||
/// </summary>
|
||||
SymbolPatchPlan BuildSymbolPatchPlan(
|
||||
SemanticEditScript editScript,
|
||||
SymbolMap oldSymbolMap,
|
||||
SymbolMap newSymbolMap,
|
||||
IReadOnlyList<Attestation.FunctionDelta> deltas);
|
||||
|
||||
/// <summary>
|
||||
/// Builds normalized patch manifest from function deltas.
|
||||
/// </summary>
|
||||
PatchManifest BuildPatchManifest(
|
||||
string buildId,
|
||||
string normalizationRecipeId,
|
||||
IReadOnlyList<Attestation.FunctionDelta> deltas);
|
||||
|
||||
/// <summary>
|
||||
/// Composes all hybrid diff artifacts into one evidence object.
|
||||
/// </summary>
|
||||
HybridDiffEvidence Compose(
|
||||
IReadOnlyList<SourceFileDiff>? sourceDiffs,
|
||||
SymbolMap oldSymbolMap,
|
||||
SymbolMap newSymbolMap,
|
||||
IReadOnlyList<Attestation.FunctionDelta> deltas,
|
||||
string normalizationRecipeId);
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic digest of a serializable value.
|
||||
/// </summary>
|
||||
string ComputeDigest<T>(T value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic implementation of hybrid diff composition.
|
||||
/// </summary>
|
||||
public sealed class HybridDiffComposer : IHybridDiffComposer
|
||||
{
|
||||
private static readonly JsonSerializerOptions DigestJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ControlKeywords =
|
||||
[
|
||||
"if",
|
||||
"for",
|
||||
"while",
|
||||
"switch",
|
||||
"catch",
|
||||
"return",
|
||||
"sizeof"
|
||||
];
|
||||
|
||||
private static readonly Regex FunctionAnchorRegex = new(
|
||||
@"(?<name>[A-Za-z_][A-Za-z0-9_:\.]*)\s*\(",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <inheritdoc />
|
||||
public SemanticEditScript GenerateSemanticEditScript(IReadOnlyList<SourceFileDiff>? sourceDiffs)
|
||||
{
|
||||
var diffs = (sourceDiffs ?? Array.Empty<SourceFileDiff>())
|
||||
.OrderBy(d => NormalizePath(d.Path), StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var edits = new List<SemanticEdit>();
|
||||
var treeMaterial = new StringBuilder();
|
||||
|
||||
foreach (var diff in diffs)
|
||||
{
|
||||
var normalizedPath = NormalizePath(diff.Path);
|
||||
var before = diff.BeforeContent ?? string.Empty;
|
||||
var after = diff.AfterContent ?? string.Empty;
|
||||
var beforeDigest = ComputeDigest(before);
|
||||
var afterDigest = ComputeDigest(after);
|
||||
|
||||
treeMaterial
|
||||
.Append(normalizedPath)
|
||||
.Append('|')
|
||||
.Append(beforeDigest)
|
||||
.Append('|')
|
||||
.Append(afterDigest)
|
||||
.Append('\n');
|
||||
|
||||
if (string.Equals(beforeDigest, afterDigest, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var beforeSymbols = ExtractSymbolBlocks(before);
|
||||
var afterSymbols = ExtractSymbolBlocks(after);
|
||||
|
||||
if (beforeSymbols.Count == 0 && afterSymbols.Count == 0)
|
||||
{
|
||||
edits.Add(CreateFileEdit(normalizedPath, beforeDigest, afterDigest));
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var symbol in beforeSymbols.Keys.Except(afterSymbols.Keys, StringComparer.Ordinal).OrderBy(v => v, StringComparer.Ordinal))
|
||||
{
|
||||
var pre = beforeSymbols[symbol];
|
||||
edits.Add(CreateSymbolEdit(
|
||||
normalizedPath,
|
||||
symbol,
|
||||
"remove",
|
||||
pre.Hash,
|
||||
null,
|
||||
new SourceSpan { StartLine = pre.StartLine, EndLine = pre.EndLine },
|
||||
null));
|
||||
}
|
||||
|
||||
foreach (var symbol in afterSymbols.Keys.Except(beforeSymbols.Keys, StringComparer.Ordinal).OrderBy(v => v, StringComparer.Ordinal))
|
||||
{
|
||||
var post = afterSymbols[symbol];
|
||||
edits.Add(CreateSymbolEdit(
|
||||
normalizedPath,
|
||||
symbol,
|
||||
"add",
|
||||
null,
|
||||
post.Hash,
|
||||
null,
|
||||
new SourceSpan { StartLine = post.StartLine, EndLine = post.EndLine }));
|
||||
}
|
||||
|
||||
foreach (var symbol in beforeSymbols.Keys.Intersect(afterSymbols.Keys, StringComparer.Ordinal).OrderBy(v => v, StringComparer.Ordinal))
|
||||
{
|
||||
var pre = beforeSymbols[symbol];
|
||||
var post = afterSymbols[symbol];
|
||||
if (!string.Equals(pre.Hash, post.Hash, StringComparison.Ordinal))
|
||||
{
|
||||
edits.Add(CreateSymbolEdit(
|
||||
normalizedPath,
|
||||
symbol,
|
||||
"update",
|
||||
pre.Hash,
|
||||
post.Hash,
|
||||
new SourceSpan { StartLine = pre.StartLine, EndLine = pre.EndLine },
|
||||
new SourceSpan { StartLine = post.StartLine, EndLine = post.EndLine }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var orderedEdits = edits
|
||||
.OrderBy(e => e.NodePath, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.EditType, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return new SemanticEditScript
|
||||
{
|
||||
SourceTreeDigest = ComputeDigest(treeMaterial.ToString()),
|
||||
Edits = orderedEdits
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SymbolMap BuildSymbolMap(SymbolManifest manifest, string? binaryDigest = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var sourcePathByCompiled = (manifest.SourceMappings ?? Array.Empty<SourceMapping>())
|
||||
.GroupBy(m => m.CompiledPath, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().SourcePath, StringComparer.Ordinal);
|
||||
|
||||
var symbols = manifest.Symbols
|
||||
.OrderBy(s => s.Address)
|
||||
.ThenBy(s => s.MangledName, StringComparer.Ordinal)
|
||||
.Select(s =>
|
||||
{
|
||||
var size = s.Size == 0 ? 1UL : s.Size;
|
||||
var mappedPath = ResolveSourcePath(s.SourceFile, sourcePathByCompiled);
|
||||
var ranges = mappedPath is null || s.SourceLine is null
|
||||
? null
|
||||
: new[]
|
||||
{
|
||||
new SourceRange
|
||||
{
|
||||
File = NormalizePath(mappedPath),
|
||||
LineStart = s.SourceLine.Value,
|
||||
LineEnd = s.SourceLine.Value
|
||||
}
|
||||
};
|
||||
|
||||
return new SymbolMapEntry
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(s.DemangledName) ? s.MangledName : s.DemangledName,
|
||||
Kind = MapSymbolKind(s.Type),
|
||||
AddressStart = s.Address,
|
||||
AddressEnd = s.Address + size - 1UL,
|
||||
Section = ".text",
|
||||
SourceRanges = ranges
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new SymbolMap
|
||||
{
|
||||
BuildId = manifest.DebugId,
|
||||
BinaryDigest = binaryDigest,
|
||||
AddressSource = "manifest",
|
||||
Symbols = symbols
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SymbolMap BuildFallbackSymbolMap(DeltaSignature signature, BinaryReference binary, string role)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signature);
|
||||
ArgumentNullException.ThrowIfNull(binary);
|
||||
|
||||
var sha = GetDigestString(binary.Digest);
|
||||
var buildId = string.IsNullOrWhiteSpace(sha)
|
||||
? $"{role}-fallback"
|
||||
: $"{role}:{sha[..Math.Min(16, sha.Length)]}";
|
||||
|
||||
ulong nextAddress = string.Equals(role, "old", StringComparison.OrdinalIgnoreCase)
|
||||
? 0x100000UL
|
||||
: 0x200000UL;
|
||||
|
||||
var symbols = new List<SymbolMapEntry>();
|
||||
foreach (var symbol in signature.Symbols.OrderBy(s => s.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var size = symbol.SizeBytes <= 0 ? 1UL : (ulong)symbol.SizeBytes;
|
||||
var start = nextAddress;
|
||||
var end = start + size - 1UL;
|
||||
|
||||
symbols.Add(new SymbolMapEntry
|
||||
{
|
||||
Name = symbol.Name,
|
||||
Kind = "function",
|
||||
AddressStart = start,
|
||||
AddressEnd = end,
|
||||
Section = symbol.Scope,
|
||||
SourceRanges = null
|
||||
});
|
||||
|
||||
var aligned = ((size + 15UL) / 16UL) * 16UL;
|
||||
nextAddress += aligned;
|
||||
}
|
||||
|
||||
return new SymbolMap
|
||||
{
|
||||
BuildId = buildId,
|
||||
BinaryDigest = string.IsNullOrWhiteSpace(sha) ? null : $"sha256:{sha}",
|
||||
AddressSource = "synthetic-signature",
|
||||
Symbols = symbols
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SymbolPatchPlan BuildSymbolPatchPlan(
|
||||
SemanticEditScript editScript,
|
||||
SymbolMap oldSymbolMap,
|
||||
SymbolMap newSymbolMap,
|
||||
IReadOnlyList<Attestation.FunctionDelta> deltas)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(editScript);
|
||||
ArgumentNullException.ThrowIfNull(oldSymbolMap);
|
||||
ArgumentNullException.ThrowIfNull(newSymbolMap);
|
||||
ArgumentNullException.ThrowIfNull(deltas);
|
||||
|
||||
var editsDigest = ComputeDigest(editScript);
|
||||
var oldMapDigest = ComputeDigest(oldSymbolMap);
|
||||
var newMapDigest = ComputeDigest(newSymbolMap);
|
||||
|
||||
var changes = deltas
|
||||
.OrderBy(d => d.FunctionId, StringComparer.Ordinal)
|
||||
.Select(delta =>
|
||||
{
|
||||
var anchors = editScript.Edits
|
||||
.Where(e => IsAnchorMatch(e.Anchor, delta.FunctionId))
|
||||
.Select(e => e.Anchor)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (anchors.Count == 0)
|
||||
{
|
||||
anchors.Add(delta.FunctionId);
|
||||
}
|
||||
|
||||
return new SymbolPatchChange
|
||||
{
|
||||
Symbol = delta.FunctionId,
|
||||
ChangeType = delta.ChangeType,
|
||||
AstAnchors = anchors,
|
||||
PreHash = delta.OldHash,
|
||||
PostHash = delta.NewHash,
|
||||
DeltaRef = "sha256:" + ComputeDigest($"{delta.FunctionId}|{delta.OldHash}|{delta.NewHash}|{delta.OldSize}|{delta.NewSize}")
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new SymbolPatchPlan
|
||||
{
|
||||
BuildIdBefore = oldSymbolMap.BuildId,
|
||||
BuildIdAfter = newSymbolMap.BuildId,
|
||||
EditsDigest = editsDigest,
|
||||
SymbolMapDigestBefore = oldMapDigest,
|
||||
SymbolMapDigestAfter = newMapDigest,
|
||||
Changes = changes
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PatchManifest BuildPatchManifest(
|
||||
string buildId,
|
||||
string normalizationRecipeId,
|
||||
IReadOnlyList<Attestation.FunctionDelta> deltas)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(buildId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(normalizationRecipeId);
|
||||
ArgumentNullException.ThrowIfNull(deltas);
|
||||
|
||||
var patches = deltas
|
||||
.OrderBy(d => d.FunctionId, StringComparer.Ordinal)
|
||||
.Select(delta =>
|
||||
{
|
||||
var start = delta.Address < 0 ? 0UL : (ulong)delta.Address;
|
||||
var rangeSize = delta.NewSize > 0 ? delta.NewSize : delta.OldSize;
|
||||
var end = rangeSize > 0
|
||||
? start + (ulong)rangeSize - 1UL
|
||||
: start;
|
||||
|
||||
return new SymbolPatchArtifact
|
||||
{
|
||||
Symbol = delta.FunctionId,
|
||||
AddressRange = $"0x{start:x}-0x{end:x}",
|
||||
DeltaDigest = "sha256:" + ComputeDigest($"{delta.FunctionId}|{delta.OldHash}|{delta.NewHash}|{delta.OldSize}|{delta.NewSize}|{delta.DiffLen}"),
|
||||
Pre = new PatchSizeHash
|
||||
{
|
||||
Size = delta.OldSize,
|
||||
Hash = string.IsNullOrWhiteSpace(delta.OldHash) ? "sha256:0" : delta.OldHash!
|
||||
},
|
||||
Post = new PatchSizeHash
|
||||
{
|
||||
Size = delta.NewSize,
|
||||
Hash = string.IsNullOrWhiteSpace(delta.NewHash) ? "sha256:0" : delta.NewHash!
|
||||
}
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new PatchManifest
|
||||
{
|
||||
BuildId = buildId,
|
||||
NormalizationRecipeId = normalizationRecipeId,
|
||||
Patches = patches
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public HybridDiffEvidence Compose(
|
||||
IReadOnlyList<SourceFileDiff>? sourceDiffs,
|
||||
SymbolMap oldSymbolMap,
|
||||
SymbolMap newSymbolMap,
|
||||
IReadOnlyList<Attestation.FunctionDelta> deltas,
|
||||
string normalizationRecipeId)
|
||||
{
|
||||
var script = GenerateSemanticEditScript(sourceDiffs);
|
||||
var patchPlan = BuildSymbolPatchPlan(script, oldSymbolMap, newSymbolMap, deltas);
|
||||
var patchManifest = BuildPatchManifest(newSymbolMap.BuildId, normalizationRecipeId, deltas);
|
||||
|
||||
var scriptDigest = ComputeDigest(script);
|
||||
var oldMapDigest = ComputeDigest(oldSymbolMap);
|
||||
var newMapDigest = ComputeDigest(newSymbolMap);
|
||||
var patchPlanDigest = ComputeDigest(patchPlan);
|
||||
var patchManifestDigest = ComputeDigest(patchManifest);
|
||||
|
||||
return new HybridDiffEvidence
|
||||
{
|
||||
SemanticEditScript = script,
|
||||
OldSymbolMap = oldSymbolMap,
|
||||
NewSymbolMap = newSymbolMap,
|
||||
SymbolPatchPlan = patchPlan,
|
||||
PatchManifest = patchManifest,
|
||||
SemanticEditScriptDigest = scriptDigest,
|
||||
OldSymbolMapDigest = oldMapDigest,
|
||||
NewSymbolMapDigest = newMapDigest,
|
||||
SymbolPatchPlanDigest = patchPlanDigest,
|
||||
PatchManifestDigest = patchManifestDigest
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ComputeDigest<T>(T value)
|
||||
{
|
||||
var json = value is string s
|
||||
? s
|
||||
: JsonSerializer.Serialize(value, DigestJsonOptions);
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
|
||||
SHA256.HashData(bytes, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? ResolveSourcePath(string? sourceFile, IReadOnlyDictionary<string, string> sourcePathByCompiled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceFile))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return sourcePathByCompiled.TryGetValue(sourceFile, out var mapped)
|
||||
? mapped
|
||||
: sourceFile;
|
||||
}
|
||||
|
||||
private static string MapSymbolKind(SymbolType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
SymbolType.Function => "function",
|
||||
SymbolType.Object or SymbolType.Variable or SymbolType.TlsData => "object",
|
||||
SymbolType.Section => "section",
|
||||
_ => "function"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetDigestString(IReadOnlyDictionary<string, string> digest)
|
||||
{
|
||||
if (digest.TryGetValue("sha256", out var sha))
|
||||
{
|
||||
return sha;
|
||||
}
|
||||
|
||||
return digest.Values.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
return path.Replace('\\', '/').Trim();
|
||||
}
|
||||
|
||||
private static SemanticEdit CreateFileEdit(string path, string beforeDigest, string afterDigest)
|
||||
{
|
||||
var type = string.IsNullOrWhiteSpace(beforeDigest) || beforeDigest == ComputeEmptyDigest()
|
||||
? "add"
|
||||
: string.IsNullOrWhiteSpace(afterDigest) || afterDigest == ComputeEmptyDigest()
|
||||
? "remove"
|
||||
: "update";
|
||||
|
||||
var nodePath = $"{path}::file";
|
||||
var stableId = ComputeStableId(path, nodePath, type, beforeDigest, afterDigest);
|
||||
return new SemanticEdit
|
||||
{
|
||||
StableId = stableId,
|
||||
EditType = type,
|
||||
NodeKind = "file",
|
||||
NodePath = nodePath,
|
||||
Anchor = path,
|
||||
PreDigest = beforeDigest,
|
||||
PostDigest = afterDigest
|
||||
};
|
||||
}
|
||||
|
||||
private static SemanticEdit CreateSymbolEdit(
|
||||
string path,
|
||||
string symbol,
|
||||
string type,
|
||||
string? preDigest,
|
||||
string? postDigest,
|
||||
SourceSpan? preSpan,
|
||||
SourceSpan? postSpan)
|
||||
{
|
||||
var nodePath = $"{path}::{symbol}";
|
||||
var stableId = ComputeStableId(path, nodePath, type, preDigest, postDigest);
|
||||
|
||||
return new SemanticEdit
|
||||
{
|
||||
StableId = stableId,
|
||||
EditType = type,
|
||||
NodeKind = "method",
|
||||
NodePath = nodePath,
|
||||
Anchor = symbol,
|
||||
PreSpan = preSpan,
|
||||
PostSpan = postSpan,
|
||||
PreDigest = preDigest,
|
||||
PostDigest = postDigest
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeStableId(string path, string nodePath, string type, string? preDigest, string? postDigest)
|
||||
{
|
||||
var material = $"{path}|{nodePath}|{type}|{preDigest}|{postDigest}";
|
||||
var bytes = Encoding.UTF8.GetBytes(material);
|
||||
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
|
||||
SHA256.HashData(bytes, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static Dictionary<string, SymbolBlock> ExtractSymbolBlocks(string content)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
var blocks = new Dictionary<string, SymbolBlock>(StringComparer.Ordinal);
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var match = FunctionAnchorRegex.Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = match.Groups["name"].Value;
|
||||
if (ControlKeywords.Contains(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var startLine = i + 1;
|
||||
var endLine = startLine;
|
||||
|
||||
var depth = CountChar(line, '{') - CountChar(line, '}');
|
||||
var foundOpening = line.Contains('{', StringComparison.Ordinal);
|
||||
|
||||
var j = i;
|
||||
while (foundOpening && depth > 0 && j + 1 < lines.Length)
|
||||
{
|
||||
j++;
|
||||
var candidate = lines[j];
|
||||
depth += CountChar(candidate, '{');
|
||||
depth -= CountChar(candidate, '}');
|
||||
}
|
||||
|
||||
if (foundOpening)
|
||||
{
|
||||
endLine = j + 1;
|
||||
i = j;
|
||||
}
|
||||
|
||||
var sliceStart = startLine - 1;
|
||||
var sliceLength = endLine - startLine + 1;
|
||||
var blockContent = string.Join("\n", lines.Skip(sliceStart).Take(sliceLength));
|
||||
var blockHash = ComputeBlockHash(blockContent);
|
||||
|
||||
blocks[name] = new SymbolBlock(name, blockHash, startLine, endLine);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private static int CountChar(string value, char token)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (c == token)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string ComputeBlockHash(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
|
||||
SHA256.HashData(bytes, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool IsAnchorMatch(string anchor, string functionId)
|
||||
{
|
||||
if (string.Equals(anchor, functionId, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return anchor.EndsWith($".{functionId}", StringComparison.Ordinal) ||
|
||||
anchor.EndsWith($"::{functionId}", StringComparison.Ordinal) ||
|
||||
anchor.Contains(functionId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string ComputeEmptyDigest()
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
|
||||
SHA256.HashData(Array.Empty<byte>(), hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed record SymbolBlock(string Name, string Hash, int StartLine, int EndLine);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.BinaryIndex.DeltaSig.Attestation;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.BinaryIndex.DeltaSig;
|
||||
|
||||
@@ -166,6 +167,35 @@ public sealed record DeltaSigRequest
|
||||
/// for the two-tier bundle format.
|
||||
/// </summary>
|
||||
public bool IncludeLargeBlobs { get; init; } = true;
|
||||
/// <summary>
|
||||
/// Source file pairs used to generate semantic edit scripts.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SourceFileDiff>? SourceDiffs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Old symbol map from build/debug metadata.
|
||||
/// </summary>
|
||||
public SymbolMap? OldSymbolMap { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New symbol map from build/debug metadata.
|
||||
/// </summary>
|
||||
public SymbolMap? NewSymbolMap { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional old symbol manifest used to derive symbol map.
|
||||
/// </summary>
|
||||
public SymbolManifest? OldSymbolManifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional new symbol manifest used to derive symbol map.
|
||||
/// </summary>
|
||||
public SymbolManifest? NewSymbolManifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include composed hybrid diff evidence in predicate output.
|
||||
/// </summary>
|
||||
public bool IncludeHybridDiffEvidence { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -296,6 +326,10 @@ public enum DeltaSigVerificationStatus
|
||||
/// </summary>
|
||||
FunctionNotFound,
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid evidence artifacts are inconsistent or invalid.
|
||||
/// </summary>
|
||||
HybridEvidenceMismatch,
|
||||
/// <summary>
|
||||
/// Binary analysis failed.
|
||||
/// </summary>
|
||||
@@ -398,6 +432,30 @@ public sealed record DeltaSigPolicyOptions
|
||||
/// Required diffing algorithm.
|
||||
/// </summary>
|
||||
public string? RequiredDiffAlgorithm { get; init; }
|
||||
/// <summary>
|
||||
/// Require hybrid diff evidence to be present.
|
||||
/// </summary>
|
||||
public bool RequireHybridEvidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Require each changed symbol to map to at least one AST anchor.
|
||||
/// </summary>
|
||||
public bool RequireAstAnchors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol prefixes that are denied from change scope.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? DeniedSymbolPrefixes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol prefixes considered protected and therefore immutable.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? ProtectedSymbolPrefixes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional maximum byte budget from patch manifest delta totals.
|
||||
/// </summary>
|
||||
public long? MaxPatchManifestDeltaBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -442,3 +500,4 @@ public sealed record DeltaSigPolicyResult
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ public static class ServiceCollectionExtensions
|
||||
logger);
|
||||
});
|
||||
|
||||
services.AddSingleton<IHybridDiffComposer, HybridDiffComposer>();
|
||||
services.AddSingleton<ISymbolChangeTracer, SymbolChangeTracer>();
|
||||
services.AddSingleton<IDeltaSignatureMatcher, DeltaSignatureMatcher>();
|
||||
|
||||
@@ -105,3 +106,4 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.GroundTruth.Abstractions\StellaOps.BinaryIndex.GroundTruth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.Normalization\StellaOps.BinaryIndex.Normalization.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.Semantic\StellaOps.BinaryIndex.Semantic.csproj" />
|
||||
<ProjectReference Include="..\..\..\Symbols\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -26,3 +27,4 @@
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# StellaOps.BinaryIndex.DeltaSig Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260216_001_BinaryIndex_hybrid_diff_patch_pipeline.md` (hybrid diff) and `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md` (remediation backlog).
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| BHP-01..05 | DONE | SPRINT_20260216_001: implementing hybrid source-symbol-binary diff pipeline (semantic edits, symbol maps, patch manifests, verifier and policy hooks). |
|
||||
| QA-BINARYINDEX-VERIFY-032 | DOING | SPRINT_20260211_033 run-001: verifying `symbol-source-connectors` with Tier 0/1/2 evidence and claim-parity review. |
|
||||
| QA-BINARYINDEX-VERIFY-031 | DONE | SPRINT_20260211_033 run-001: Tier 0/1/2 command checks passed, but claim-parity review terminalized `symbol-change-tracking-in-binary-diffs` as `not_implemented` because `IrDiffGenerator` is still placeholder-backed. |
|
||||
| QA-BINARYINDEX-VERIFY-015 | DONE | SPRINT_20260211_033 run-002: remediated PatchCoverage runtime wiring and rechecked Tier 0/1/2; terminalized `delta-signature-matching-and-patch-coverage-analysis` as `not_implemented` because `IrDiffGenerator` remains placeholder-backed. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/BinaryIndex/__Libraries/StellaOps.BinaryIndex.DeltaSig/StellaOps.BinaryIndex.DeltaSig.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -9,3 +9,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0738-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0738-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| BHP-05-API-HYBRID-20260217 | DONE | Added contract JSON roundtrip assertions for ResolutionEvidence.hybridDiff and function-level fields. |
|
||||
|
||||
|
||||
@@ -75,7 +75,89 @@ public sealed class VulnResolutionContractsTests
|
||||
{
|
||||
MatchType = ResolutionMatchTypes.HashExact,
|
||||
Confidence = 0.9m,
|
||||
FixMethod = ResolutionFixMethods.SecurityFeed
|
||||
FixMethod = ResolutionFixMethods.SecurityFeed,
|
||||
FixConfidence = 0.9m,
|
||||
ChangedFunctions =
|
||||
[
|
||||
new FunctionChangeInfo
|
||||
{
|
||||
Name = "openssl::verify",
|
||||
ChangeType = "Modified",
|
||||
Similarity = 0.82m,
|
||||
VulnerableSize = 304,
|
||||
PatchedSize = 312
|
||||
}
|
||||
],
|
||||
HybridDiff = new HybridDiffEvidence
|
||||
{
|
||||
SemanticEditScriptDigest = "sha256:edits",
|
||||
OldSymbolMapDigest = "sha256:old-map",
|
||||
NewSymbolMapDigest = "sha256:new-map",
|
||||
SymbolPatchPlanDigest = "sha256:plan",
|
||||
PatchManifestDigest = "sha256:manifest",
|
||||
SemanticEditScript = new SemanticEditScriptArtifact
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
SourceTreeDigest = "sha256:tree",
|
||||
Edits =
|
||||
[
|
||||
new SemanticEditRecord
|
||||
{
|
||||
StableId = "sha256:edit-1",
|
||||
EditType = "update",
|
||||
NodeKind = "method",
|
||||
NodePath = "openssl::verify",
|
||||
Anchor = "openssl::verify"
|
||||
}
|
||||
]
|
||||
},
|
||||
SymbolPatchPlan = new SymbolPatchPlanArtifact
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
BuildIdBefore = "baseline:build-id",
|
||||
BuildIdAfter = "build-id",
|
||||
EditsDigest = "sha256:edits",
|
||||
SymbolMapDigestBefore = "sha256:old-map",
|
||||
SymbolMapDigestAfter = "sha256:new-map",
|
||||
Changes =
|
||||
[
|
||||
new SymbolPatchChange
|
||||
{
|
||||
Symbol = "openssl::verify",
|
||||
ChangeType = "modified",
|
||||
AstAnchors = ["openssl::verify"],
|
||||
DeltaRef = "sha256:delta"
|
||||
}
|
||||
]
|
||||
},
|
||||
PatchManifest = new PatchManifestArtifact
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
BuildId = "build-id",
|
||||
NormalizationRecipeId = "recipe-v1",
|
||||
TotalDeltaBytes = 8,
|
||||
Patches =
|
||||
[
|
||||
new SymbolPatchArtifact
|
||||
{
|
||||
Symbol = "openssl::verify",
|
||||
AddressRange = "0x401120-0x4012AF",
|
||||
DeltaDigest = "sha256:delta",
|
||||
DeltaSizeBytes = 8,
|
||||
Pre = new PatchSizeHash
|
||||
{
|
||||
Size = 304,
|
||||
Hash = "sha256:pre"
|
||||
},
|
||||
Post = new PatchSizeHash
|
||||
{
|
||||
Size = 312,
|
||||
Hash = "sha256:post"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,6 +171,12 @@ public sealed class VulnResolutionContractsTests
|
||||
roundTrip.ResolvedAt.Should().Be(response.ResolvedAt);
|
||||
roundTrip.Evidence!.MatchType.Should().Be(response.Evidence!.MatchType);
|
||||
roundTrip.Evidence!.FixMethod.Should().Be(response.Evidence!.FixMethod);
|
||||
roundTrip.Evidence!.FixConfidence.Should().Be(response.Evidence!.FixConfidence);
|
||||
roundTrip.Evidence!.ChangedFunctions.Should().HaveCount(1);
|
||||
roundTrip.Evidence!.ChangedFunctions![0].Name.Should().Be("openssl::verify");
|
||||
roundTrip.Evidence!.HybridDiff.Should().NotBeNull();
|
||||
roundTrip.Evidence!.HybridDiff!.PatchManifestDigest.Should().Be("sha256:manifest");
|
||||
roundTrip.Evidence!.HybridDiff!.PatchManifest!.Patches.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
private static List<ValidationResult> Validate(object instance)
|
||||
@@ -98,3 +186,4 @@ public sealed class VulnResolutionContractsTests
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,80 @@ public sealed class ResolutionServiceTests
|
||||
result.Status.Should().Be(ResolutionStatus.Unknown);
|
||||
result.Evidence!.MatchType.Should().Be(ResolutionMatchTypes.Fingerprint);
|
||||
}
|
||||
[Fact]
|
||||
public async Task ResolveAsync_IdentityMatch_EmitsHybridDiffEvidence()
|
||||
{
|
||||
var stub = new StubBinaryVulnerabilityService
|
||||
{
|
||||
OnIdentity = _ =>
|
||||
[
|
||||
new BinaryVulnMatch
|
||||
{
|
||||
CveId = "CVE-2024-1111",
|
||||
VulnerablePurl = "pkg:deb/debian/openssl@1.2.3",
|
||||
Method = MatchMethod.BuildIdCatalog,
|
||||
Confidence = 0.97m,
|
||||
Evidence = new MatchEvidence
|
||||
{
|
||||
MatchedFunction = "openssl::verify_chain",
|
||||
Similarity = 0.89m
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var service = CreateService(stub);
|
||||
|
||||
var request = new VulnResolutionRequest
|
||||
{
|
||||
Package = "pkg:deb/debian/openssl@1.2.3",
|
||||
BuildId = "build-id"
|
||||
};
|
||||
|
||||
var result = await service.ResolveAsync(request, ct: TestContext.Current.CancellationToken);
|
||||
|
||||
result.Status.Should().Be(ResolutionStatus.Fixed);
|
||||
result.Evidence.Should().NotBeNull();
|
||||
result.Evidence!.ChangedFunctions.Should().ContainSingle();
|
||||
result.Evidence!.ChangedFunctions![0].Name.Should().Be("openssl::verify_chain");
|
||||
result.Evidence!.HybridDiff.Should().NotBeNull();
|
||||
result.Evidence!.HybridDiff!.PatchManifest.Should().NotBeNull();
|
||||
result.Evidence!.HybridDiff!.PatchManifest!.Patches.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_SpecificCve_EmitsHybridDiffEvidence()
|
||||
{
|
||||
var stub = new StubBinaryVulnerabilityService
|
||||
{
|
||||
OnFixStatus = (_, _, _, _) => new FixStatusResult
|
||||
{
|
||||
State = FixState.Fixed,
|
||||
FixedVersion = "1.0.1",
|
||||
Method = FixMethod.PatchHeader,
|
||||
Confidence = 0.91m,
|
||||
EvidenceId = Guid.NewGuid()
|
||||
}
|
||||
};
|
||||
|
||||
var service = CreateService(stub);
|
||||
|
||||
var request = new VulnResolutionRequest
|
||||
{
|
||||
Package = "pkg:deb/debian/openssl@1.2.3",
|
||||
BuildId = "build-id",
|
||||
CveId = "CVE-2024-2222"
|
||||
};
|
||||
|
||||
var result = await service.ResolveAsync(request, ct: TestContext.Current.CancellationToken);
|
||||
|
||||
result.Status.Should().Be(ResolutionStatus.Fixed);
|
||||
result.FixedVersion.Should().Be("1.0.1");
|
||||
result.Evidence.Should().NotBeNull();
|
||||
result.Evidence!.FixMethod.Should().Be(ResolutionFixMethods.PatchHeader);
|
||||
result.Evidence!.HybridDiff.Should().NotBeNull();
|
||||
result.Evidence!.HybridDiff!.SemanticEditScript!.Edits.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveBatchAsync_TruncatesToMaxBatchSize()
|
||||
@@ -122,6 +196,7 @@ public sealed class ResolutionServiceTests
|
||||
{
|
||||
public Func<BinaryIdentity, ImmutableArray<BinaryVulnMatch>>? OnIdentity { get; init; }
|
||||
public Func<byte[], ImmutableArray<BinaryVulnMatch>>? OnFingerprint { get; init; }
|
||||
public Func<string, string, string, string, FixStatusResult?>? OnFixStatus { get; init; }
|
||||
|
||||
public Task<ImmutableArray<BinaryVulnMatch>> LookupByIdentityAsync(
|
||||
BinaryIdentity identity,
|
||||
@@ -148,7 +223,8 @@ public sealed class ResolutionServiceTests
|
||||
string cveId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult<FixStatusResult?>(null);
|
||||
var status = OnFixStatus?.Invoke(distro, release, sourcePkg, cveId);
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public Task<ImmutableDictionary<string, FixStatusResult>> GetFixStatusBatchAsync(
|
||||
@@ -227,3 +303,4 @@ public sealed class ResolutionServiceTests
|
||||
public override DateTimeOffset GetUtcNow() => _fixed;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,3 +10,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0117-T | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0117-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| BHP-05-API-HYBRID-20260217 | DONE | Added resolution behavioral tests validating hybrid diff evidence emission for identity and CVE-specific resolution paths. |
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.BinaryIndex.DeltaSig.Attestation;
|
||||
|
||||
namespace StellaOps.BinaryIndex.DeltaSig.Tests;
|
||||
|
||||
public sealed class DeltaSigServiceHybridPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void EvaluatePolicy_EnforcesHybridEvidenceAndNamespaceControls()
|
||||
{
|
||||
var service = CreateService();
|
||||
var predicate = CreatePredicate(CreateHybridEvidence(
|
||||
symbol: "Crypto.Core.Encrypt",
|
||||
anchors: [],
|
||||
deltaBytes: 24));
|
||||
|
||||
var result = service.EvaluatePolicy(predicate, new DeltaSigPolicyOptions
|
||||
{
|
||||
RequireHybridEvidence = true,
|
||||
RequireAstAnchors = true,
|
||||
DeniedSymbolPrefixes = ["Crypto."],
|
||||
MaxPatchManifestDeltaBytes = 8
|
||||
});
|
||||
|
||||
result.Passed.Should().BeFalse();
|
||||
result.Violations.Should().Contain(v => v.Contains("AST anchors", StringComparison.Ordinal));
|
||||
result.Violations.Should().Contain(v => v.Contains("Denied symbol prefix", StringComparison.Ordinal));
|
||||
result.Violations.Should().Contain(v => v.Contains("Patch manifest changed", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluatePolicy_PassesWhenHybridEvidenceIsCompliant()
|
||||
{
|
||||
var service = CreateService();
|
||||
var predicate = CreatePredicate(CreateHybridEvidence(
|
||||
symbol: "Safe.Module.Apply",
|
||||
anchors: ["Safe.Module.Apply"],
|
||||
deltaBytes: 4));
|
||||
|
||||
var result = service.EvaluatePolicy(predicate, new DeltaSigPolicyOptions
|
||||
{
|
||||
RequireHybridEvidence = true,
|
||||
RequireAstAnchors = true,
|
||||
MaxPatchManifestDeltaBytes = 16,
|
||||
DeniedSymbolPrefixes = ["Crypto."],
|
||||
ProtectedSymbolPrefixes = ["Immutable.Namespace."]
|
||||
});
|
||||
|
||||
result.Passed.Should().BeTrue();
|
||||
result.Violations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
private static DeltaSigService CreateService()
|
||||
{
|
||||
return new DeltaSigService(
|
||||
Mock.Of<IDeltaSignatureGenerator>(),
|
||||
Mock.Of<IDeltaSignatureMatcher>(),
|
||||
NullLogger<DeltaSigService>.Instance,
|
||||
new HybridDiffComposer());
|
||||
}
|
||||
|
||||
private static DeltaSigPredicate CreatePredicate(HybridDiffEvidence? hybrid)
|
||||
{
|
||||
return new DeltaSigPredicate
|
||||
{
|
||||
Subject =
|
||||
[
|
||||
new DeltaSigSubject
|
||||
{
|
||||
Uri = "oci://old",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "old" },
|
||||
Arch = "linux-amd64",
|
||||
Role = "old"
|
||||
},
|
||||
new DeltaSigSubject
|
||||
{
|
||||
Uri = "oci://new",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "new" },
|
||||
Arch = "linux-amd64",
|
||||
Role = "new"
|
||||
}
|
||||
],
|
||||
Delta =
|
||||
[
|
||||
new FunctionDelta
|
||||
{
|
||||
FunctionId = hybrid?.SymbolPatchPlan.Changes.FirstOrDefault()?.Symbol ?? "unknown",
|
||||
Address = 0x1000,
|
||||
OldHash = "a",
|
||||
NewHash = "b",
|
||||
OldSize = 10,
|
||||
NewSize = 12,
|
||||
DiffLen = 2,
|
||||
ChangeType = "modified"
|
||||
}
|
||||
],
|
||||
Summary = new DeltaSummary
|
||||
{
|
||||
TotalFunctions = 1,
|
||||
FunctionsAdded = 0,
|
||||
FunctionsRemoved = 0,
|
||||
FunctionsModified = 1,
|
||||
FunctionsUnchanged = 0,
|
||||
TotalBytesChanged = 2,
|
||||
MinSemanticSimilarity = 1,
|
||||
AvgSemanticSimilarity = 1,
|
||||
MaxSemanticSimilarity = 1
|
||||
},
|
||||
Tooling = new DeltaTooling
|
||||
{
|
||||
Lifter = "b2r2",
|
||||
LifterVersion = "0.7.0",
|
||||
CanonicalIr = "b2r2-lowuir",
|
||||
DiffAlgorithm = "ir-semantic"
|
||||
},
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
HybridDiff = hybrid
|
||||
};
|
||||
}
|
||||
|
||||
private static HybridDiffEvidence CreateHybridEvidence(string symbol, IReadOnlyList<string> anchors, long deltaBytes)
|
||||
{
|
||||
var composer = new HybridDiffComposer();
|
||||
|
||||
var oldMap = new SymbolMap
|
||||
{
|
||||
BuildId = "old-build",
|
||||
Symbols =
|
||||
[
|
||||
new SymbolMapEntry
|
||||
{
|
||||
Name = symbol,
|
||||
AddressStart = 0x1000,
|
||||
AddressEnd = 0x100f,
|
||||
Section = ".text"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var newMap = new SymbolMap
|
||||
{
|
||||
BuildId = "new-build",
|
||||
Symbols =
|
||||
[
|
||||
new SymbolMapEntry
|
||||
{
|
||||
Name = symbol,
|
||||
AddressStart = 0x2000,
|
||||
AddressEnd = 0x200f,
|
||||
Section = ".text"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var functionDelta = new FunctionDelta
|
||||
{
|
||||
FunctionId = symbol,
|
||||
Address = 0x2000,
|
||||
OldHash = "old-hash",
|
||||
NewHash = "new-hash",
|
||||
OldSize = 16,
|
||||
NewSize = 16 + deltaBytes,
|
||||
DiffLen = deltaBytes,
|
||||
ChangeType = "modified"
|
||||
};
|
||||
|
||||
var evidence = composer.Compose(
|
||||
sourceDiffs: [],
|
||||
oldSymbolMap: oldMap,
|
||||
newSymbolMap: newMap,
|
||||
deltas: [functionDelta],
|
||||
normalizationRecipeId: "recipe-1");
|
||||
|
||||
var changes = evidence.SymbolPatchPlan.Changes
|
||||
.Select(c => c with { AstAnchors = anchors })
|
||||
.ToList();
|
||||
|
||||
var updatedPlan = evidence.SymbolPatchPlan with { Changes = changes };
|
||||
|
||||
return evidence with
|
||||
{
|
||||
SymbolPatchPlan = updatedPlan,
|
||||
SymbolPatchPlanDigest = composer.ComputeDigest(updatedPlan)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.BinaryIndex.DeltaSig.Attestation;
|
||||
|
||||
namespace StellaOps.BinaryIndex.DeltaSig.Tests;
|
||||
|
||||
public sealed class DeltaSigServiceVerificationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsHybridEvidenceMismatch_WhenHybridDigestsAreInvalid()
|
||||
{
|
||||
var binary = new MemoryStream([1, 2, 3, 4, 5]);
|
||||
var newDigest = ComputeSha256(binary);
|
||||
|
||||
var generator = new Mock<IDeltaSignatureGenerator>();
|
||||
generator
|
||||
.Setup(x => x.GenerateSignaturesAsync(
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<DeltaSignatureRequest>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DeltaSignature
|
||||
{
|
||||
Cve = "verification",
|
||||
Package = new PackageRef("pkg", null),
|
||||
Target = new TargetRef("x86_64", "gnu"),
|
||||
Normalization = new NormalizationRef("recipe-1", "1.0.0", []),
|
||||
SignatureState = "verification",
|
||||
Symbols = []
|
||||
});
|
||||
|
||||
var service = new DeltaSigService(
|
||||
generator.Object,
|
||||
Mock.Of<IDeltaSignatureMatcher>(),
|
||||
NullLogger<DeltaSigService>.Instance,
|
||||
new HybridDiffComposer());
|
||||
|
||||
var hybrid = CreateHybridEvidenceWithInvalidDigest();
|
||||
|
||||
var predicate = new DeltaSigPredicate
|
||||
{
|
||||
Subject =
|
||||
[
|
||||
new DeltaSigSubject
|
||||
{
|
||||
Uri = "oci://old",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "old" },
|
||||
Arch = "linux-amd64",
|
||||
Role = "old"
|
||||
},
|
||||
new DeltaSigSubject
|
||||
{
|
||||
Uri = "oci://new",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = newDigest },
|
||||
Arch = "linux-amd64",
|
||||
Role = "new"
|
||||
}
|
||||
],
|
||||
Delta = [],
|
||||
Summary = new DeltaSummary
|
||||
{
|
||||
TotalFunctions = 0,
|
||||
FunctionsAdded = 0,
|
||||
FunctionsRemoved = 0,
|
||||
FunctionsModified = 0,
|
||||
FunctionsUnchanged = 0,
|
||||
TotalBytesChanged = 0,
|
||||
MinSemanticSimilarity = 1,
|
||||
AvgSemanticSimilarity = 1,
|
||||
MaxSemanticSimilarity = 1
|
||||
},
|
||||
Tooling = new DeltaTooling
|
||||
{
|
||||
Lifter = "b2r2",
|
||||
LifterVersion = "0.7.0",
|
||||
CanonicalIr = "b2r2-lowuir",
|
||||
DiffAlgorithm = "ir-semantic"
|
||||
},
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
HybridDiff = hybrid
|
||||
};
|
||||
|
||||
binary.Position = 0;
|
||||
var result = await service.VerifyAsync(predicate, binary);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Status.Should().Be(DeltaSigVerificationStatus.HybridEvidenceMismatch);
|
||||
result.Message.Should().Contain("semantic_edit_script");
|
||||
}
|
||||
|
||||
private static string ComputeSha256(Stream stream)
|
||||
{
|
||||
stream.Position = 0;
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
stream.Position = 0;
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static HybridDiffEvidence CreateHybridEvidenceWithInvalidDigest()
|
||||
{
|
||||
var composer = new HybridDiffComposer();
|
||||
|
||||
var oldMap = new SymbolMap
|
||||
{
|
||||
BuildId = "old-build",
|
||||
BinaryDigest = "sha256:old",
|
||||
Symbols = []
|
||||
};
|
||||
|
||||
var newMap = new SymbolMap
|
||||
{
|
||||
BuildId = "new-build",
|
||||
BinaryDigest = "sha256:new",
|
||||
Symbols = []
|
||||
};
|
||||
|
||||
var evidence = composer.Compose(
|
||||
sourceDiffs: [],
|
||||
oldSymbolMap: oldMap,
|
||||
newSymbolMap: newMap,
|
||||
deltas: [],
|
||||
normalizationRecipeId: "recipe-1");
|
||||
|
||||
return evidence with { SemanticEditScriptDigest = "tampered-digest" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.BinaryIndex.DeltaSig.Attestation;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.BinaryIndex.DeltaSig.Tests;
|
||||
|
||||
public sealed class HybridDiffComposerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Compose_WithIdenticalInputs_IsDeterministic()
|
||||
{
|
||||
var composer = new HybridDiffComposer();
|
||||
var sourceDiffs = new[]
|
||||
{
|
||||
new SourceFileDiff
|
||||
{
|
||||
Path = "src/Example.cs",
|
||||
BeforeContent = "class C { int Add(int a, int b) { return a + b; } }",
|
||||
AfterContent = "class C { int Add(int a, int b) { return a + b + 1; } }"
|
||||
}
|
||||
};
|
||||
|
||||
var oldMap = new SymbolMap
|
||||
{
|
||||
BuildId = "build-old",
|
||||
BinaryDigest = "sha256:old",
|
||||
Symbols =
|
||||
[
|
||||
new SymbolMapEntry
|
||||
{
|
||||
Name = "C::Add",
|
||||
AddressStart = 0x401000,
|
||||
AddressEnd = 0x40103F,
|
||||
Section = ".text"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var newMap = new SymbolMap
|
||||
{
|
||||
BuildId = "build-new",
|
||||
BinaryDigest = "sha256:new",
|
||||
Symbols =
|
||||
[
|
||||
new SymbolMapEntry
|
||||
{
|
||||
Name = "C::Add",
|
||||
AddressStart = 0x501000,
|
||||
AddressEnd = 0x501047,
|
||||
Section = ".text"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var deltas = new[]
|
||||
{
|
||||
new FunctionDelta
|
||||
{
|
||||
FunctionId = "C::Add",
|
||||
Address = 0x501000,
|
||||
OldHash = "old-hash",
|
||||
NewHash = "new-hash",
|
||||
OldSize = 64,
|
||||
NewSize = 72,
|
||||
DiffLen = 8,
|
||||
ChangeType = "modified"
|
||||
}
|
||||
};
|
||||
|
||||
var left = composer.Compose(sourceDiffs, oldMap, newMap, deltas, "recipe-1");
|
||||
var right = composer.Compose(sourceDiffs, oldMap, newMap, deltas, "recipe-1");
|
||||
|
||||
left.SemanticEditScriptDigest.Should().Be(right.SemanticEditScriptDigest);
|
||||
left.OldSymbolMapDigest.Should().Be(right.OldSymbolMapDigest);
|
||||
left.NewSymbolMapDigest.Should().Be(right.NewSymbolMapDigest);
|
||||
left.SymbolPatchPlanDigest.Should().Be(right.SymbolPatchPlanDigest);
|
||||
left.PatchManifestDigest.Should().Be(right.PatchManifestDigest);
|
||||
left.PatchManifest.Patches.Should().ContainSingle(p => p.Symbol == "C::Add");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSymbolMap_MapsSourcePaths_AndOrdersByAddress()
|
||||
{
|
||||
var composer = new HybridDiffComposer();
|
||||
|
||||
var manifest = new SymbolManifest
|
||||
{
|
||||
ManifestId = "manifest-1",
|
||||
DebugId = "dbg-1",
|
||||
BinaryName = "sample.bin",
|
||||
Format = BinaryFormat.Elf,
|
||||
TenantId = "tenant-a",
|
||||
Symbols =
|
||||
[
|
||||
new SymbolEntry
|
||||
{
|
||||
Address = 0x401100,
|
||||
Size = 16,
|
||||
MangledName = "b",
|
||||
DemangledName = "B::Method",
|
||||
Type = SymbolType.Function,
|
||||
SourceFile = "/obj/B.cs",
|
||||
SourceLine = 20
|
||||
},
|
||||
new SymbolEntry
|
||||
{
|
||||
Address = 0x401000,
|
||||
Size = 32,
|
||||
MangledName = "a",
|
||||
DemangledName = "A::Method",
|
||||
Type = SymbolType.Function,
|
||||
SourceFile = "/obj/A.cs",
|
||||
SourceLine = 10
|
||||
}
|
||||
],
|
||||
SourceMappings =
|
||||
[
|
||||
new SourceMapping
|
||||
{
|
||||
CompiledPath = "/obj/A.cs",
|
||||
SourcePath = "src/A.cs"
|
||||
},
|
||||
new SourceMapping
|
||||
{
|
||||
CompiledPath = "/obj/B.cs",
|
||||
SourcePath = "src/B.cs"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var map = composer.BuildSymbolMap(manifest, "sha256:abc");
|
||||
|
||||
map.BuildId.Should().Be("dbg-1");
|
||||
map.Symbols.Should().HaveCount(2);
|
||||
map.Symbols[0].Name.Should().Be("A::Method");
|
||||
map.Symbols[1].Name.Should().Be("B::Method");
|
||||
map.Symbols[0].SourceRanges.Should().ContainSingle(r => r.File == "src/A.cs" && r.LineStart == 10 && r.LineEnd == 10);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
# BinaryIndex DeltaSig Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260216_001_BinaryIndex_hybrid_diff_patch_pipeline.md` (hybrid tests) and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md` (historical baseline).
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| BHP-TEST-20260216 | DONE | SPRINT_20260216_001: targeted behavioral tests for hybrid diff composer/service policy and verifier logic. |
|
||||
| QA-BINARYINDEX-VERIFY-034 | DONE | SPRINT_20260211_033 run-002: expanded golden CVE fixture package coverage to include glibc/zlib/curl and added regression assertion for required high-impact package set. |
|
||||
| QA-BINARYINDEX-VERIFY-032 | DOING | SPRINT_20260211_033 run-001: executing Tier 0/1/2 verification for `symbol-source-connectors` with deterministic behavioral evidence capture. |
|
||||
| QA-BINARYINDEX-VERIFY-031 | DONE | SPRINT_20260211_033 run-001: executed Tier 0/1/2 verification for `symbol-change-tracking-in-binary-diffs`; terminalized feature as `not_implemented` due missing IR-diff behavioral implementation and test coverage. |
|
||||
@@ -13,3 +14,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0743-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0743-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -155,6 +155,46 @@ public sealed class CachedResolutionServiceTests
|
||||
cache.GetCalls.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FromCache_ProvidesHybridDiffEvidence()
|
||||
{
|
||||
var fakeInner = new FakeResolutionService(_timeProvider);
|
||||
var cache = new FakeResolutionCacheService();
|
||||
var cacheOptions = Options.Create(new ResolutionCacheOptions());
|
||||
var serviceOptions = Options.Create(new ResolutionServiceOptions());
|
||||
|
||||
var service = new CachedResolutionService(
|
||||
fakeInner,
|
||||
cache,
|
||||
cacheOptions,
|
||||
serviceOptions,
|
||||
_timeProvider,
|
||||
NullLogger<CachedResolutionService>.Instance);
|
||||
|
||||
var request = new VulnResolutionRequest
|
||||
{
|
||||
Package = "pkg:deb/debian/openssl@3.0.7",
|
||||
BuildId = "build-hybrid"
|
||||
};
|
||||
|
||||
var cacheKey = cache.GenerateCacheKey(request);
|
||||
cache.Entries[cacheKey] = new CachedResolution
|
||||
{
|
||||
Status = ResolutionStatus.Fixed,
|
||||
FixedVersion = "1.0.2",
|
||||
CachedAt = _timeProvider.GetUtcNow(),
|
||||
Confidence = 0.93m,
|
||||
MatchType = ResolutionMatchTypes.BuildId
|
||||
};
|
||||
|
||||
var result = await service.ResolveAsync(request, null, TestContext.Current.CancellationToken);
|
||||
|
||||
result.FromCache.Should().BeTrue();
|
||||
result.Evidence.Should().NotBeNull();
|
||||
result.Evidence!.HybridDiff.Should().NotBeNull();
|
||||
result.Evidence!.HybridDiff!.PatchManifestDigest.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_BypassCache_SkipsCache()
|
||||
{
|
||||
@@ -529,3 +569,4 @@ internal sealed class FakeResolutionCacheService : IResolutionCacheService
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,3 +16,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0747-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0747-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| BHP-05-API-HYBRID-20260217 | DONE | Added cached resolution behavioral test proving evidence.hybridDiff is present on cache hits. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user