notify doctors work, audit work, new product advisory sprints
This commit is contained in:
569
docs/dev/extending-binary-analysis.md
Normal file
569
docs/dev/extending-binary-analysis.md
Normal file
@@ -0,0 +1,569 @@
|
||||
# Extending Binary Analysis
|
||||
|
||||
This guide explains how to add support for new binary formats or custom section extractors to the binary diff attestation system.
|
||||
|
||||
## Overview
|
||||
|
||||
The binary analysis system is designed for extensibility. You can add support for:
|
||||
|
||||
- **New binary formats** (PE, Mach-O, WebAssembly)
|
||||
- **Custom section extractors** (additional ELF sections, custom hash algorithms)
|
||||
- **Verdict classifiers** (custom backport detection logic)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Interfaces
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Binary Analysis Pipeline │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ IBinaryFormatDetector ──▶ ISectionHashExtractor<TConfig> │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ BinaryFormat enum SectionHashSet │
|
||||
│ (elf, pe, macho) (per-format) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ IVerdictClassifier │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ BinaryDiffFinding │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Interfaces
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Detects binary format from file magic/headers.
|
||||
/// </summary>
|
||||
public interface IBinaryFormatDetector
|
||||
{
|
||||
BinaryFormat Detect(ReadOnlySpan<byte> header);
|
||||
BinaryFormat DetectFromPath(string filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts section hashes for a specific binary format.
|
||||
/// </summary>
|
||||
public interface ISectionHashExtractor<TConfig> where TConfig : class
|
||||
{
|
||||
BinaryFormat SupportedFormat { get; }
|
||||
|
||||
Task<SectionHashSet?> ExtractAsync(
|
||||
string filePath,
|
||||
TConfig? config = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SectionHashSet?> ExtractFromBytesAsync(
|
||||
ReadOnlyMemory<byte> bytes,
|
||||
string virtualPath,
|
||||
TConfig? config = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies binary changes as patched/vanilla/unknown.
|
||||
/// </summary>
|
||||
public interface IVerdictClassifier
|
||||
{
|
||||
Verdict Classify(SectionHashSet? baseHashes, SectionHashSet? targetHashes);
|
||||
double ComputeConfidence(SectionHashSet? baseHashes, SectionHashSet? targetHashes);
|
||||
}
|
||||
```
|
||||
|
||||
## Adding a New Binary Format
|
||||
|
||||
### Step 1: Define Configuration
|
||||
|
||||
```csharp
|
||||
// src/Scanner/__Libraries/StellaOps.Scanner.Contracts/PeSectionConfig.cs
|
||||
|
||||
namespace StellaOps.Scanner.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for PE section hash extraction.
|
||||
/// </summary>
|
||||
public sealed record PeSectionConfig
|
||||
{
|
||||
/// <summary>Sections to extract hashes from.</summary>
|
||||
public ImmutableArray<string> Sections { get; init; } = [".text", ".rdata", ".data", ".rsrc"];
|
||||
|
||||
/// <summary>Hash algorithms to use.</summary>
|
||||
public ImmutableArray<string> HashAlgorithms { get; init; } = ["sha256"];
|
||||
|
||||
/// <summary>Maximum section size to process (bytes).</summary>
|
||||
public long MaxSectionSize { get; init; } = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
/// <summary>Whether to extract version resources.</summary>
|
||||
public bool ExtractVersionInfo { get; init; } = true;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Implement the Extractor
|
||||
|
||||
```csharp
|
||||
// src/Scanner/StellaOps.Scanner.Analyzers.Native/Hardening/PeSectionHashExtractor.cs
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native;
|
||||
|
||||
public sealed class PeSectionHashExtractor : ISectionHashExtractor<PeSectionConfig>
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PeSectionHashExtractor> _logger;
|
||||
|
||||
public PeSectionHashExtractor(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PeSectionHashExtractor> logger)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public BinaryFormat SupportedFormat => BinaryFormat.Pe;
|
||||
|
||||
public async Task<SectionHashSet?> ExtractAsync(
|
||||
string filePath,
|
||||
PeSectionConfig? config = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
config ??= new PeSectionConfig();
|
||||
|
||||
// Read file
|
||||
var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
|
||||
return await ExtractFromBytesAsync(bytes, filePath, config, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SectionHashSet?> ExtractFromBytesAsync(
|
||||
ReadOnlyMemory<byte> bytes,
|
||||
string virtualPath,
|
||||
PeSectionConfig? config = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
config ??= new PeSectionConfig();
|
||||
|
||||
// Validate PE magic
|
||||
if (!IsPeFile(bytes.Span))
|
||||
{
|
||||
_logger.LogDebug("Not a PE file: {Path}", virtualPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sections = new Dictionary<string, SectionInfo>();
|
||||
|
||||
// Parse PE headers
|
||||
using var peReader = new PEReader(new MemoryStream(bytes.ToArray()));
|
||||
|
||||
foreach (var sectionHeader in peReader.PEHeaders.SectionHeaders)
|
||||
{
|
||||
var sectionName = sectionHeader.Name;
|
||||
|
||||
if (!config.Sections.Contains(sectionName))
|
||||
continue;
|
||||
|
||||
if (sectionHeader.SizeOfRawData > config.MaxSectionSize)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Section {Section} exceeds max size ({Size} > {Max})",
|
||||
sectionName, sectionHeader.SizeOfRawData, config.MaxSectionSize);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get section data
|
||||
var sectionData = peReader.GetSectionData(sectionName);
|
||||
if (sectionData.Length == 0)
|
||||
continue;
|
||||
|
||||
// Compute hash
|
||||
var sha256 = ComputeSha256(sectionData.GetContent());
|
||||
|
||||
sections[sectionName] = new SectionInfo
|
||||
{
|
||||
Sha256 = sha256,
|
||||
Size = sectionData.Length,
|
||||
Offset = sectionHeader.PointerToRawData
|
||||
};
|
||||
}
|
||||
|
||||
// Compute file hash
|
||||
var fileHash = ComputeSha256(bytes.Span);
|
||||
|
||||
return new SectionHashSet
|
||||
{
|
||||
FilePath = virtualPath,
|
||||
FileHash = fileHash,
|
||||
Sections = sections.ToImmutableDictionary(),
|
||||
ExtractedAt = _timeProvider.GetUtcNow(),
|
||||
ExtractorVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to extract PE sections from {Path}", virtualPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPeFile(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
// Check DOS header magic (MZ)
|
||||
if (bytes.Length < 64)
|
||||
return false;
|
||||
|
||||
return bytes[0] == 0x4D && bytes[1] == 0x5A; // "MZ"
|
||||
}
|
||||
|
||||
private static string ComputeSha256(ReadOnlySpan<byte> data)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(data, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Register Services
|
||||
|
||||
```csharp
|
||||
// src/Scanner/StellaOps.Scanner.Analyzers.Native/ServiceCollectionExtensions.cs
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddNativeAnalyzers(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Existing ELF extractor
|
||||
services.AddSingleton<IElfSectionHashExtractor, ElfSectionHashExtractor>();
|
||||
|
||||
// New PE extractor
|
||||
services.AddSingleton<ISectionHashExtractor<PeSectionConfig>, PeSectionHashExtractor>();
|
||||
|
||||
// Register in composite
|
||||
services.AddSingleton<IBinaryFormatDetector, CompositeBinaryFormatDetector>();
|
||||
services.AddSingleton<ICompositeSectionHashExtractor>(sp =>
|
||||
{
|
||||
var extractors = new Dictionary<BinaryFormat, object>
|
||||
{
|
||||
[BinaryFormat.Elf] = sp.GetRequiredService<IElfSectionHashExtractor>(),
|
||||
[BinaryFormat.Pe] = sp.GetRequiredService<ISectionHashExtractor<PeSectionConfig>>()
|
||||
};
|
||||
return new CompositeSectionHashExtractor(extractors);
|
||||
});
|
||||
|
||||
// Configuration
|
||||
services.AddOptions<PeSectionConfig>()
|
||||
.Bind(configuration.GetSection("Scanner:Native:PeSections"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Add Tests
|
||||
|
||||
```csharp
|
||||
// src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/PeSectionHashExtractorTests.cs
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Tests;
|
||||
|
||||
public class PeSectionHashExtractorTests
|
||||
{
|
||||
private readonly PeSectionHashExtractor _extractor;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public PeSectionHashExtractorTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
|
||||
_extractor = new PeSectionHashExtractor(
|
||||
_timeProvider,
|
||||
NullLogger<PeSectionHashExtractor>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_ValidPe_ReturnsAllSections()
|
||||
{
|
||||
// Arrange
|
||||
var pePath = "TestData/sample.exe";
|
||||
|
||||
// Act
|
||||
var result = await _extractor.ExtractAsync(pePath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains(".text", result.Sections.Keys);
|
||||
Assert.Contains(".rdata", result.Sections.Keys);
|
||||
Assert.NotEmpty(result.FileHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_NotPeFile_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var elfPath = "TestData/sample.elf";
|
||||
|
||||
// Act
|
||||
var result = await _extractor.ExtractAsync(elfPath);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_Deterministic_SameOutput()
|
||||
{
|
||||
// Arrange
|
||||
var pePath = "TestData/sample.exe";
|
||||
|
||||
// Act
|
||||
var result1 = await _extractor.ExtractAsync(pePath);
|
||||
var result2 = await _extractor.ExtractAsync(pePath);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(result1!.FileHash, result2!.FileHash);
|
||||
Assert.Equal(result1.Sections[".text"].Sha256, result2.Sections[".text"].Sha256);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Adding Custom Section Analysis
|
||||
|
||||
### Custom Hash Algorithm
|
||||
|
||||
```csharp
|
||||
public interface IHashAlgorithmProvider
|
||||
{
|
||||
string Name { get; }
|
||||
string ComputeHash(ReadOnlySpan<byte> data);
|
||||
}
|
||||
|
||||
public sealed class Blake3HashProvider : IHashAlgorithmProvider
|
||||
{
|
||||
public string Name => "blake3";
|
||||
|
||||
public string ComputeHash(ReadOnlySpan<byte> data)
|
||||
{
|
||||
// Using Blake3 library
|
||||
var hash = Blake3.Hasher.Hash(data);
|
||||
return Convert.ToHexString(hash.AsSpan()).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Verdict Classifier
|
||||
|
||||
```csharp
|
||||
public sealed class EnhancedVerdictClassifier : IVerdictClassifier
|
||||
{
|
||||
private readonly ISymbolAnalyzer _symbolAnalyzer;
|
||||
|
||||
public Verdict Classify(SectionHashSet? baseHashes, SectionHashSet? targetHashes)
|
||||
{
|
||||
if (baseHashes == null || targetHashes == null)
|
||||
return Verdict.Unknown;
|
||||
|
||||
// Check .text section change
|
||||
var textChanged = HasSectionChanged(baseHashes, targetHashes, ".text");
|
||||
var symbolsChanged = HasSectionChanged(baseHashes, targetHashes, ".symtab");
|
||||
|
||||
// Custom logic: if .text changed but symbols are similar, likely a patch
|
||||
if (textChanged && !symbolsChanged)
|
||||
{
|
||||
return Verdict.Patched;
|
||||
}
|
||||
|
||||
// If everything changed significantly, it's a vanilla update
|
||||
if (textChanged && symbolsChanged)
|
||||
{
|
||||
return Verdict.Vanilla;
|
||||
}
|
||||
|
||||
return Verdict.Unknown;
|
||||
}
|
||||
|
||||
public double ComputeConfidence(SectionHashSet? baseHashes, SectionHashSet? targetHashes)
|
||||
{
|
||||
if (baseHashes == null || targetHashes == null)
|
||||
return 0.0;
|
||||
|
||||
// Compute similarity score
|
||||
var matchingSections = 0;
|
||||
var totalSections = 0;
|
||||
|
||||
foreach (var (name, baseInfo) in baseHashes.Sections)
|
||||
{
|
||||
totalSections++;
|
||||
if (targetHashes.Sections.TryGetValue(name, out var targetInfo))
|
||||
{
|
||||
if (baseInfo.Sha256 == targetInfo.Sha256)
|
||||
matchingSections++;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalSections == 0)
|
||||
return 0.0;
|
||||
|
||||
// Higher similarity = higher confidence in classification
|
||||
return Math.Round((double)matchingSections / totalSections, 4, MidpointRounding.ToZero);
|
||||
}
|
||||
|
||||
private static bool HasSectionChanged(SectionHashSet baseHashes, SectionHashSet targetHashes, string section)
|
||||
{
|
||||
if (!baseHashes.Sections.TryGetValue(section, out var baseInfo))
|
||||
return false;
|
||||
if (!targetHashes.Sections.TryGetValue(section, out var targetInfo))
|
||||
return true;
|
||||
|
||||
return baseInfo.Sha256 != targetInfo.Sha256;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Determinism
|
||||
|
||||
Always ensure deterministic output:
|
||||
|
||||
```csharp
|
||||
// BAD - Non-deterministic
|
||||
public SectionHashSet Extract(string path)
|
||||
{
|
||||
return new SectionHashSet
|
||||
{
|
||||
ExtractedAt = DateTimeOffset.UtcNow, // Non-deterministic!
|
||||
// ...
|
||||
};
|
||||
}
|
||||
|
||||
// GOOD - Injected time provider
|
||||
public SectionHashSet Extract(string path)
|
||||
{
|
||||
return new SectionHashSet
|
||||
{
|
||||
ExtractedAt = _timeProvider.GetUtcNow(), // Deterministic
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Error Handling
|
||||
|
||||
Handle malformed binaries gracefully:
|
||||
|
||||
```csharp
|
||||
public async Task<SectionHashSet?> ExtractAsync(string path, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// ... extraction logic
|
||||
}
|
||||
catch (BadImageFormatException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Invalid binary format: {Path}", path);
|
||||
return null; // Return null, don't throw
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "I/O error reading: {Path}", path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Memory Management
|
||||
|
||||
Stream large binaries instead of loading entirely:
|
||||
|
||||
```csharp
|
||||
public async Task<SectionHashSet?> ExtractLargeBinaryAsync(
|
||||
string path,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 81920,
|
||||
useAsync: true);
|
||||
|
||||
// Stream section data instead of loading all at once
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Configuration Validation
|
||||
|
||||
Validate configuration at startup:
|
||||
|
||||
```csharp
|
||||
public sealed class PeSectionConfigValidator : IValidateOptions<PeSectionConfig>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, PeSectionConfig options)
|
||||
{
|
||||
if (options.Sections.Length == 0)
|
||||
return ValidateOptionsResult.Fail("At least one section must be specified");
|
||||
|
||||
if (options.MaxSectionSize <= 0)
|
||||
return ValidateOptionsResult.Fail("MaxSectionSize must be positive");
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Golden File Tests
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Extract_KnownBinary_MatchesGolden()
|
||||
{
|
||||
// Arrange
|
||||
var binaryPath = "TestData/known-binary.exe";
|
||||
var goldenPath = "TestData/known-binary.golden.json";
|
||||
|
||||
// Act
|
||||
var result = await _extractor.ExtractAsync(binaryPath);
|
||||
|
||||
// Assert
|
||||
var expected = JsonSerializer.Deserialize<SectionHashSet>(
|
||||
await File.ReadAllTextAsync(goldenPath));
|
||||
|
||||
Assert.Equal(expected!.FileHash, result!.FileHash);
|
||||
Assert.Equal(expected.Sections.Count, result.Sections.Count);
|
||||
}
|
||||
```
|
||||
|
||||
### Fuzz Testing
|
||||
|
||||
```csharp
|
||||
[Theory]
|
||||
[MemberData(nameof(MalformedBinaries))]
|
||||
public async Task Extract_MalformedBinary_ReturnsNullOrThrows(byte[] malformedData)
|
||||
{
|
||||
// Act & Assert - Should not crash
|
||||
var result = await _extractor.ExtractFromBytesAsync(
|
||||
malformedData,
|
||||
"test.bin");
|
||||
|
||||
// Either null or valid result, never exception
|
||||
// (Exception would fail the test)
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [PE Format Specification](https://docs.microsoft.com/en-us/windows/win32/debug/pe-format)
|
||||
- [Mach-O Format Reference](https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/MachORuntime/)
|
||||
- [ELF Specification](https://refspecs.linuxfoundation.org/elf/elf.pdf)
|
||||
- [Binary Diff Attestation Architecture](../modules/scanner/binary-diff-attestation.md)
|
||||
416
docs/doctor/README.md
Normal file
416
docs/doctor/README.md
Normal file
@@ -0,0 +1,416 @@
|
||||
# Stella Ops Doctor
|
||||
|
||||
> Self-service diagnostics for Stella Ops deployments
|
||||
|
||||
## Overview
|
||||
|
||||
The Doctor system provides comprehensive diagnostics for Stella Ops deployments, enabling operators, DevOps engineers, and developers to:
|
||||
|
||||
- **Diagnose** what is working and what is not
|
||||
- **Understand** why failures occur with collected evidence
|
||||
- **Remediate** issues with copy/paste commands
|
||||
- **Verify** fixes with re-runnable checks
|
||||
|
||||
## Quick Start
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Quick health check
|
||||
stella doctor
|
||||
|
||||
# Full diagnostic with all checks
|
||||
stella doctor --full
|
||||
|
||||
# Check specific category
|
||||
stella doctor --category database
|
||||
|
||||
# Export report for support
|
||||
stella doctor export --output diagnostic-bundle.zip
|
||||
```
|
||||
|
||||
### UI
|
||||
|
||||
Navigate to `/ops/doctor` in the Stella Ops console to access the interactive Doctor Dashboard.
|
||||
|
||||
### API
|
||||
|
||||
```bash
|
||||
# Run diagnostics
|
||||
POST /api/v1/doctor/run
|
||||
|
||||
# Get available checks
|
||||
GET /api/v1/doctor/checks
|
||||
|
||||
# Stream results
|
||||
WebSocket /api/v1/doctor/stream
|
||||
```
|
||||
|
||||
## Available Checks
|
||||
|
||||
The Doctor system includes 48+ diagnostic checks across 7 plugins:
|
||||
|
||||
| Plugin | Category | Checks | Description |
|
||||
|--------|----------|--------|-------------|
|
||||
| `stellaops.doctor.core` | Core | 9 | Configuration, runtime, disk, memory, time, crypto |
|
||||
| `stellaops.doctor.database` | Database | 8 | Connectivity, migrations, schema, connection pool |
|
||||
| `stellaops.doctor.servicegraph` | ServiceGraph | 6 | Gateway, routing, service health |
|
||||
| `stellaops.doctor.security` | Security | 9 | OIDC, LDAP, TLS, Vault |
|
||||
| `stellaops.doctor.scm.*` | Integration.SCM | 8 | GitHub, GitLab connectivity/auth/permissions |
|
||||
| `stellaops.doctor.registry.*` | Integration.Registry | 6 | Harbor, ECR connectivity/auth/pull |
|
||||
| `stellaops.doctor.observability` | Observability | 4 | OTLP, logs, metrics |
|
||||
|
||||
### Check ID Convention
|
||||
|
||||
```
|
||||
check.{category}.{subcategory}.{specific}
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `check.config.required`
|
||||
- `check.database.migrations.pending`
|
||||
- `check.services.gateway.routing`
|
||||
- `check.integration.scm.github.auth`
|
||||
|
||||
## CLI Reference
|
||||
|
||||
See [CLI Reference](./cli-reference.md) for complete command documentation.
|
||||
|
||||
### Common Commands
|
||||
|
||||
```bash
|
||||
# Quick health check (tagged 'quick' checks only)
|
||||
stella doctor --quick
|
||||
|
||||
# Full diagnostic with all checks
|
||||
stella doctor --full
|
||||
|
||||
# Filter by category
|
||||
stella doctor --category database
|
||||
stella doctor --category security
|
||||
|
||||
# Filter by plugin
|
||||
stella doctor --plugin scm.github
|
||||
|
||||
# Run single check
|
||||
stella doctor --check check.database.migrations.pending
|
||||
|
||||
# Output formats
|
||||
stella doctor --format json
|
||||
stella doctor --format markdown
|
||||
stella doctor --format text
|
||||
|
||||
# Filter output by severity
|
||||
stella doctor --severity fail,warn
|
||||
|
||||
# Export diagnostic bundle
|
||||
stella doctor export --output diagnostic.zip
|
||||
stella doctor export --include-logs --log-duration 4h
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | All checks passed |
|
||||
| 1 | One or more warnings |
|
||||
| 2 | One or more failures |
|
||||
| 3 | Doctor engine error |
|
||||
| 4 | Invalid arguments |
|
||||
| 5 | Timeout exceeded |
|
||||
|
||||
## Output Example
|
||||
|
||||
```
|
||||
Stella Ops Doctor
|
||||
=================
|
||||
|
||||
Running 47 checks across 8 plugins...
|
||||
|
||||
[PASS] check.config.required
|
||||
All required configuration values are present
|
||||
|
||||
[PASS] check.database.connectivity
|
||||
PostgreSQL connection successful (latency: 12ms)
|
||||
|
||||
[WARN] check.tls.certificates.expiry
|
||||
Diagnosis: TLS certificate expires in 14 days
|
||||
|
||||
Evidence:
|
||||
Certificate: /etc/ssl/certs/stellaops.crt
|
||||
Subject: CN=stellaops.example.com
|
||||
Expires: 2026-01-26T00:00:00Z
|
||||
Days remaining: 14
|
||||
|
||||
Likely Causes:
|
||||
1. Certificate renewal not scheduled
|
||||
2. ACME/Let's Encrypt automation not configured
|
||||
|
||||
Fix Steps:
|
||||
# 1. Check current certificate
|
||||
openssl x509 -in /etc/ssl/certs/stellaops.crt -noout -dates
|
||||
|
||||
# 2. Renew certificate (if using certbot)
|
||||
sudo certbot renew --cert-name stellaops.example.com
|
||||
|
||||
# 3. Restart services to pick up new certificate
|
||||
sudo systemctl restart stellaops-gateway
|
||||
|
||||
Verification:
|
||||
stella doctor --check check.tls.certificates.expiry
|
||||
|
||||
[FAIL] check.database.migrations.pending
|
||||
Diagnosis: 3 pending release migrations detected in schema 'auth'
|
||||
|
||||
Evidence:
|
||||
Schema: auth
|
||||
Current version: 099_add_dpop_thumbprints
|
||||
Pending migrations:
|
||||
- 100_add_tenant_quotas
|
||||
- 101_add_audit_retention
|
||||
- 102_add_session_revocation
|
||||
|
||||
Likely Causes:
|
||||
1. Release migrations not applied before deployment
|
||||
2. Migration files added after last deployment
|
||||
|
||||
Fix Steps:
|
||||
# 1. Backup database first (RECOMMENDED)
|
||||
pg_dump -h localhost -U stella_admin -d stellaops -F c \
|
||||
-f stellaops_backup_$(date +%Y%m%d_%H%M%S).dump
|
||||
|
||||
# 2. Apply pending release migrations
|
||||
stella system migrations-run --module Authority --category release
|
||||
|
||||
# 3. Verify migrations applied
|
||||
stella system migrations-status --module Authority
|
||||
|
||||
Verification:
|
||||
stella doctor --check check.database.migrations.pending
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
Summary: 44 passed, 2 warnings, 1 failed (47 total)
|
||||
Duration: 8.3s
|
||||
--------------------------------------------------------------------------------
|
||||
```
|
||||
|
||||
## Export Bundle
|
||||
|
||||
The Doctor export feature creates a diagnostic bundle for support escalation:
|
||||
|
||||
```bash
|
||||
stella doctor export --output diagnostic-bundle.zip
|
||||
```
|
||||
|
||||
The bundle contains:
|
||||
- `doctor-report.json` - Full diagnostic report
|
||||
- `doctor-report.md` - Human-readable report
|
||||
- `environment.json` - Environment information
|
||||
- `system-info.json` - System details (OS, runtime, memory)
|
||||
- `config-sanitized.json` - Sanitized configuration (secrets redacted)
|
||||
- `logs/` - Recent log files (optional)
|
||||
- `README.md` - Bundle contents guide
|
||||
|
||||
### Export Options
|
||||
|
||||
```bash
|
||||
# Include logs from last 4 hours
|
||||
stella doctor export --include-logs --log-duration 4h
|
||||
|
||||
# Exclude configuration
|
||||
stella doctor export --no-config
|
||||
|
||||
# Custom output path
|
||||
stella doctor export --output /tmp/support-bundle.zip
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Secret Redaction
|
||||
|
||||
All evidence output is sanitized. Sensitive values (passwords, tokens, connection strings) are replaced with `***REDACTED***` in:
|
||||
- Console output
|
||||
- JSON exports
|
||||
- Diagnostic bundles
|
||||
- Log files
|
||||
|
||||
### RBAC Permissions
|
||||
|
||||
| Scope | Description |
|
||||
|-------|-------------|
|
||||
| `doctor:run` | Execute doctor checks |
|
||||
| `doctor:run:full` | Execute all checks including sensitive |
|
||||
| `doctor:export` | Export diagnostic reports |
|
||||
| `admin:system` | Access system-level checks |
|
||||
|
||||
## Plugin Development
|
||||
|
||||
To create a custom Doctor plugin, implement `IDoctorPlugin`:
|
||||
|
||||
```csharp
|
||||
public class MyCustomPlugin : IDoctorPlugin
|
||||
{
|
||||
public string PluginId => "stellaops.doctor.custom";
|
||||
public string DisplayName => "Custom Checks";
|
||||
public Version Version => new(1, 0, 0);
|
||||
public DoctorCategory Category => DoctorCategory.Integration;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
|
||||
{
|
||||
return new IDoctorCheck[]
|
||||
{
|
||||
new MyCustomCheck()
|
||||
};
|
||||
}
|
||||
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
```
|
||||
|
||||
Implement checks using `IDoctorCheck`:
|
||||
|
||||
```csharp
|
||||
public class MyCustomCheck : IDoctorCheck
|
||||
{
|
||||
public string CheckId => "check.custom.mycheck";
|
||||
public string Name => "My Custom Check";
|
||||
public string Description => "Validates custom configuration";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
public IReadOnlyList<string> Tags => new[] { "custom", "quick" };
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(2);
|
||||
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(
|
||||
DoctorPluginContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Perform check logic
|
||||
var isValid = await ValidateAsync(ct);
|
||||
|
||||
if (isValid)
|
||||
{
|
||||
return DoctorCheckResult.Pass(
|
||||
checkId: CheckId,
|
||||
diagnosis: "Custom configuration is valid",
|
||||
evidence: new Evidence
|
||||
{
|
||||
Description = "Validation passed",
|
||||
Data = new Dictionary<string, string>
|
||||
{
|
||||
["validated_at"] = context.TimeProvider.GetUtcNow().ToString("O")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return DoctorCheckResult.Fail(
|
||||
checkId: CheckId,
|
||||
diagnosis: "Custom configuration is invalid",
|
||||
evidence: new Evidence
|
||||
{
|
||||
Description = "Validation failed",
|
||||
Data = new Dictionary<string, string>
|
||||
{
|
||||
["error"] = "Configuration file missing"
|
||||
}
|
||||
},
|
||||
remediation: new Remediation
|
||||
{
|
||||
Steps = new[]
|
||||
{
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Create configuration file",
|
||||
Command = "cp /etc/stellaops/custom.yaml.sample /etc/stellaops/custom.yaml",
|
||||
CommandType = CommandType.Shell
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Register the plugin in DI:
|
||||
|
||||
```csharp
|
||||
services.AddSingleton<IDoctorPlugin, MyCustomPlugin>();
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| CLI | | UI | | External |
|
||||
| stella doctor | | /ops/doctor | | Monitoring |
|
||||
+--------+---------+ +--------+---------+ +--------+---------+
|
||||
| | |
|
||||
v v v
|
||||
+------------------------------------------------------------------------+
|
||||
| Doctor API Layer |
|
||||
| POST /api/v1/doctor/run GET /api/v1/doctor/checks |
|
||||
| GET /api/v1/doctor/report WebSocket /api/v1/doctor/stream |
|
||||
+------------------------------------------------------------------------+
|
||||
|
|
||||
v
|
||||
+------------------------------------------------------------------------+
|
||||
| Doctor Engine (Core) |
|
||||
| +------------------+ +------------------+ +------------------+ |
|
||||
| | Check Registry | | Check Executor | | Report Generator | |
|
||||
| | - Discovery | | - Parallel exec | | - JSON/MD/Text | |
|
||||
| | - Filtering | | - Timeout mgmt | | - Remediation | |
|
||||
| +------------------+ +------------------+ +------------------+ |
|
||||
+------------------------------------------------------------------------+
|
||||
|
|
||||
v
|
||||
+------------------------------------------------------------------------+
|
||||
| Plugin System |
|
||||
+--------+---------+---------+---------+---------+---------+-------------+
|
||||
| | | | | |
|
||||
v v v v v v
|
||||
+--------+ +------+ +------+ +------+ +------+ +------+ +----------+
|
||||
| Core | | DB & | |Service| | SCM | |Regis-| |Observ-| |Security |
|
||||
| Plugin | |Migra-| | Graph | |Plugin| | try | |ability| | Plugin |
|
||||
| | | tions| |Plugin | | | |Plugin| |Plugin | | |
|
||||
+--------+ +------+ +------+ +------+ +------+ +------+ +----------+
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [CLI Reference](./cli-reference.md) - Complete CLI command reference
|
||||
- [Doctor Capabilities Specification](./doctor-capabilities.md) - Full technical specification
|
||||
- [Plugin Development Guide](./plugin-development.md) - Creating custom plugins
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Doctor Engine Error (Exit Code 3)
|
||||
|
||||
If `stella doctor` returns exit code 3:
|
||||
|
||||
1. Check the error message for details
|
||||
2. Verify required services are running
|
||||
3. Check connectivity to databases
|
||||
4. Review logs at `/var/log/stellaops/doctor.log`
|
||||
|
||||
### Timeout Exceeded (Exit Code 5)
|
||||
|
||||
If checks are timing out:
|
||||
|
||||
```bash
|
||||
# Increase per-check timeout
|
||||
stella doctor --timeout 60s
|
||||
|
||||
# Run with reduced parallelism
|
||||
stella doctor --parallel 2
|
||||
```
|
||||
|
||||
### Checks Not Found
|
||||
|
||||
If expected checks are not appearing:
|
||||
|
||||
1. Verify plugin is registered in DI
|
||||
2. Check `CanRun()` returns true for your environment
|
||||
3. Review plugin initialization logs
|
||||
396
docs/doctor/cli-reference.md
Normal file
396
docs/doctor/cli-reference.md
Normal file
@@ -0,0 +1,396 @@
|
||||
# Doctor CLI Reference
|
||||
|
||||
> Complete reference for `stella doctor` commands
|
||||
|
||||
## Commands
|
||||
|
||||
### stella doctor
|
||||
|
||||
Run diagnostic checks.
|
||||
|
||||
```bash
|
||||
stella doctor [options]
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Short | Type | Default | Description |
|
||||
|--------|-------|------|---------|-------------|
|
||||
| `--format` | `-f` | enum | `text` | Output format: `text`, `json`, `markdown` |
|
||||
| `--quick` | `-q` | flag | false | Run only quick checks (tagged `quick`) |
|
||||
| `--full` | | flag | false | Run all checks including slow/intensive |
|
||||
| `--category` | `-c` | string[] | all | Filter by category |
|
||||
| `--plugin` | `-p` | string[] | all | Filter by plugin ID |
|
||||
| `--check` | | string | | Run single check by ID |
|
||||
| `--severity` | `-s` | enum[] | all | Filter output by severity |
|
||||
| `--timeout` | `-t` | duration | 30s | Per-check timeout |
|
||||
| `--parallel` | | int | 4 | Max parallel check execution |
|
||||
| `--no-remediation` | | flag | false | Skip remediation output |
|
||||
| `--verbose` | `-v` | flag | false | Include detailed evidence |
|
||||
|
||||
#### Categories
|
||||
|
||||
- `core` - Configuration, runtime, system checks
|
||||
- `database` - Database connectivity, migrations, pools
|
||||
- `service-graph` - Service health, gateway, routing
|
||||
- `security` - Authentication, TLS, secrets
|
||||
- `integration` - SCM, registry integrations
|
||||
- `observability` - Telemetry, logging, metrics
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
# Quick health check
|
||||
stella doctor
|
||||
|
||||
# Full diagnostic
|
||||
stella doctor --full
|
||||
|
||||
# Database checks only
|
||||
stella doctor --category database
|
||||
|
||||
# GitHub integration checks
|
||||
stella doctor --plugin scm.github
|
||||
|
||||
# Single check
|
||||
stella doctor --check check.database.connectivity
|
||||
|
||||
# JSON output (for CI/CD)
|
||||
stella doctor --format json
|
||||
|
||||
# Show only failures and warnings
|
||||
stella doctor --severity fail,warn
|
||||
|
||||
# Markdown report
|
||||
stella doctor --format markdown > doctor-report.md
|
||||
|
||||
# Verbose with all evidence
|
||||
stella doctor --verbose
|
||||
|
||||
# Custom timeout and parallelism
|
||||
stella doctor --timeout 60s --parallel 2
|
||||
```
|
||||
|
||||
### stella doctor export
|
||||
|
||||
Generate a diagnostic bundle for support.
|
||||
|
||||
```bash
|
||||
stella doctor export [options]
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `--output` | path | `diagnostic-bundle.zip` | Output file path |
|
||||
| `--include-logs` | flag | false | Include recent log files |
|
||||
| `--log-duration` | duration | `1h` | Duration of logs to include |
|
||||
| `--no-config` | flag | false | Exclude configuration |
|
||||
|
||||
#### Duration Format
|
||||
|
||||
Duration values can be specified as:
|
||||
- `30m` - 30 minutes
|
||||
- `1h` - 1 hour
|
||||
- `4h` - 4 hours
|
||||
- `24h` or `1d` - 24 hours
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
# Basic export
|
||||
stella doctor export --output diagnostic.zip
|
||||
|
||||
# Include logs from last 4 hours
|
||||
stella doctor export --include-logs --log-duration 4h
|
||||
|
||||
# Without configuration (for privacy)
|
||||
stella doctor export --no-config
|
||||
|
||||
# Full bundle with logs
|
||||
stella doctor export \
|
||||
--output support-bundle.zip \
|
||||
--include-logs \
|
||||
--log-duration 24h
|
||||
```
|
||||
|
||||
#### Bundle Contents
|
||||
|
||||
The export creates a ZIP archive containing:
|
||||
|
||||
```
|
||||
diagnostic-bundle.zip
|
||||
+-- README.md # Bundle contents guide
|
||||
+-- doctor-report.json # Full diagnostic report
|
||||
+-- doctor-report.md # Human-readable report
|
||||
+-- environment.json # Environment information
|
||||
+-- system-info.json # System details
|
||||
+-- config-sanitized.json # Configuration (secrets redacted)
|
||||
+-- logs/ # Log files (if --include-logs)
|
||||
+-- stellaops-*.log
|
||||
```
|
||||
|
||||
### stella doctor list
|
||||
|
||||
List available checks.
|
||||
|
||||
```bash
|
||||
stella doctor list [options]
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `--category` | string | Filter by category |
|
||||
| `--plugin` | string | Filter by plugin |
|
||||
| `--format` | enum | Output format: `text`, `json` |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
# List all checks
|
||||
stella doctor list
|
||||
|
||||
# List database checks
|
||||
stella doctor list --category database
|
||||
|
||||
# List as JSON
|
||||
stella doctor list --format json
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Name | Description |
|
||||
|------|------|-------------|
|
||||
| 0 | `Success` | All checks passed |
|
||||
| 1 | `Warnings` | One or more warnings, no failures |
|
||||
| 2 | `Failures` | One or more checks failed |
|
||||
| 3 | `EngineError` | Doctor engine error |
|
||||
| 4 | `InvalidArgs` | Invalid command arguments |
|
||||
| 5 | `Timeout` | Timeout exceeded |
|
||||
|
||||
### Using Exit Codes in Scripts
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
stella doctor --format json > report.json
|
||||
exit_code=$?
|
||||
|
||||
case $exit_code in
|
||||
0)
|
||||
echo "All checks passed"
|
||||
;;
|
||||
1)
|
||||
echo "Warnings detected - review report"
|
||||
;;
|
||||
2)
|
||||
echo "Failures detected - action required"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Doctor error (code: $exit_code)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
- name: Run Stella Doctor
|
||||
run: |
|
||||
stella doctor --format json --severity fail,warn > doctor-report.json
|
||||
exit_code=$?
|
||||
if [ $exit_code -eq 2 ]; then
|
||||
echo "::error::Doctor checks failed"
|
||||
cat doctor-report.json
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### GitLab CI
|
||||
|
||||
```yaml
|
||||
doctor:
|
||||
stage: validate
|
||||
script:
|
||||
- stella doctor --format json > doctor-report.json
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- doctor-report.json
|
||||
allow_failure:
|
||||
exit_codes:
|
||||
- 1 # Allow warnings
|
||||
```
|
||||
|
||||
### Jenkins
|
||||
|
||||
```groovy
|
||||
stage('Health Check') {
|
||||
steps {
|
||||
script {
|
||||
def result = sh(
|
||||
script: 'stella doctor --format json',
|
||||
returnStatus: true
|
||||
)
|
||||
if (result == 2) {
|
||||
error "Doctor checks failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
### Text Format (Default)
|
||||
|
||||
Human-readable console output with colors and formatting.
|
||||
|
||||
```
|
||||
Stella Ops Doctor
|
||||
=================
|
||||
|
||||
Running 47 checks across 8 plugins...
|
||||
|
||||
[PASS] check.config.required
|
||||
All required configuration values are present
|
||||
|
||||
[FAIL] check.database.migrations.pending
|
||||
Diagnosis: 3 pending migrations in schema 'auth'
|
||||
|
||||
Fix Steps:
|
||||
# Apply migrations
|
||||
stella system migrations-run --module Authority
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
Summary: 46 passed, 0 warnings, 1 failed (47 total)
|
||||
Duration: 8.3s
|
||||
--------------------------------------------------------------------------------
|
||||
```
|
||||
|
||||
### JSON Format
|
||||
|
||||
Machine-readable format for automation:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"total": 47,
|
||||
"passed": 46,
|
||||
"warnings": 0,
|
||||
"failures": 1,
|
||||
"skipped": 0,
|
||||
"duration": "PT8.3S"
|
||||
},
|
||||
"executedAt": "2026-01-12T14:30:00Z",
|
||||
"checks": [
|
||||
{
|
||||
"checkId": "check.config.required",
|
||||
"pluginId": "stellaops.doctor.core",
|
||||
"category": "Core",
|
||||
"severity": "Pass",
|
||||
"diagnosis": "All required configuration values are present",
|
||||
"evidence": {
|
||||
"description": "Configuration validated",
|
||||
"data": {
|
||||
"configSource": "appsettings.json",
|
||||
"keysChecked": "42"
|
||||
}
|
||||
},
|
||||
"duration": "PT0.012S"
|
||||
},
|
||||
{
|
||||
"checkId": "check.database.migrations.pending",
|
||||
"pluginId": "stellaops.doctor.database",
|
||||
"category": "Database",
|
||||
"severity": "Fail",
|
||||
"diagnosis": "3 pending migrations in schema 'auth'",
|
||||
"evidence": {
|
||||
"description": "Migration status",
|
||||
"data": {
|
||||
"schema": "auth",
|
||||
"pendingCount": "3"
|
||||
}
|
||||
},
|
||||
"remediation": {
|
||||
"steps": [
|
||||
{
|
||||
"order": 1,
|
||||
"description": "Apply pending migrations",
|
||||
"command": "stella system migrations-run --module Authority",
|
||||
"commandType": "Shell"
|
||||
}
|
||||
]
|
||||
},
|
||||
"duration": "PT0.234S"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Markdown Format
|
||||
|
||||
Formatted for documentation and reports:
|
||||
|
||||
```markdown
|
||||
# Stella Ops Doctor Report
|
||||
|
||||
**Generated:** 2026-01-12T14:30:00Z
|
||||
**Duration:** 8.3s
|
||||
|
||||
## Summary
|
||||
|
||||
| Status | Count |
|
||||
|--------|-------|
|
||||
| Passed | 46 |
|
||||
| Warnings | 0 |
|
||||
| Failures | 1 |
|
||||
| Skipped | 0 |
|
||||
| **Total** | **47** |
|
||||
|
||||
## Failed Checks
|
||||
|
||||
### check.database.migrations.pending
|
||||
|
||||
**Status:** FAIL
|
||||
**Plugin:** stellaops.doctor.database
|
||||
**Category:** Database
|
||||
|
||||
**Diagnosis:** 3 pending migrations in schema 'auth'
|
||||
|
||||
**Evidence:**
|
||||
- Schema: auth
|
||||
- Pending count: 3
|
||||
|
||||
**Fix Steps:**
|
||||
1. Apply pending migrations
|
||||
```bash
|
||||
stella system migrations-run --module Authority
|
||||
```
|
||||
|
||||
## Passed Checks
|
||||
|
||||
- check.config.required
|
||||
- check.database.connectivity
|
||||
- ... (44 more)
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `STELLAOPS_DOCTOR_TIMEOUT` | Default per-check timeout |
|
||||
| `STELLAOPS_DOCTOR_PARALLEL` | Default parallelism |
|
||||
| `STELLAOPS_CONFIG_PATH` | Configuration file path |
|
||||
|
||||
## See Also
|
||||
|
||||
- [Doctor Overview](./README.md)
|
||||
- [Doctor Capabilities Specification](./doctor-capabilities.md)
|
||||
55
docs/examples/binary-diff/README.md
Normal file
55
docs/examples/binary-diff/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Binary Diff Examples
|
||||
|
||||
This directory contains examples demonstrating the binary diff attestation feature.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- StellaOps CLI (`stella`) installed
|
||||
- Access to a container registry
|
||||
- Docker or containerd runtime (for image pulling)
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Comparison
|
||||
|
||||
[basic-comparison.md](./basic-comparison.md) - Simple binary diff between two image versions
|
||||
|
||||
### DSSE Attestation
|
||||
|
||||
[dsse-attestation.md](./dsse-attestation.md) - Generating and verifying DSSE-signed attestations
|
||||
|
||||
### Policy Integration
|
||||
|
||||
[policy-integration.md](./policy-integration.md) - Using binary diff evidence in policy rules
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
[ci-cd-integration.md](./ci-cd-integration.md) - GitHub Actions and GitLab CI examples
|
||||
|
||||
## Sample Outputs
|
||||
|
||||
The `sample-outputs/` directory contains:
|
||||
|
||||
- `diff-table.txt` - Sample table-formatted output
|
||||
- `diff.json` - Sample JSON output
|
||||
- `attestation.dsse.json` - Sample DSSE envelope
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Compare two image versions
|
||||
stella scan diff --base myapp:1.0.0 --target myapp:1.0.1
|
||||
|
||||
# Generate attestation
|
||||
stella scan diff --base myapp:1.0.0 --target myapp:1.0.1 \
|
||||
--mode=elf --emit-dsse=./attestations/
|
||||
|
||||
# Verify attestation
|
||||
stella verify attestation ./attestations/linux-amd64-binarydiff.dsse.json
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Binary Diff Attestation Architecture](../../modules/scanner/binary-diff-attestation.md)
|
||||
- [BinaryDiffV1 JSON Schema](../../schemas/binarydiff-v1.schema.json)
|
||||
- [CLI Reference](../../API_CLI_REFERENCE.md#stella-scan-diff)
|
||||
143
docs/examples/binary-diff/basic-comparison.md
Normal file
143
docs/examples/binary-diff/basic-comparison.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Basic Binary Comparison
|
||||
|
||||
This example demonstrates how to perform a basic binary diff between two container image versions.
|
||||
|
||||
## Scenario
|
||||
|
||||
You have deployed `myapp:1.0.0` and want to understand what binary changes are in `myapp:1.0.1` before upgrading.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
# Ensure stella CLI is installed
|
||||
stella --version
|
||||
|
||||
# Verify registry access
|
||||
stella registry ping docker://registry.example.com
|
||||
```
|
||||
|
||||
## Basic Comparison
|
||||
|
||||
### Table Output (Default)
|
||||
|
||||
```bash
|
||||
stella scan diff \
|
||||
--base docker://registry.example.com/myapp:1.0.0 \
|
||||
--target docker://registry.example.com/myapp:1.0.1
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Binary Diff: docker://registry.example.com/myapp:1.0.0 -> docker://registry.example.com/myapp:1.0.1
|
||||
Platform: linux/amd64
|
||||
Analysis Mode: ELF Section Hashes
|
||||
|
||||
PATH CHANGE VERDICT CONFIDENCE
|
||||
--------------------------------------------------------------------------------
|
||||
/usr/lib/libssl.so.3 modified patched 0.95
|
||||
/usr/lib/libcrypto.so.3 modified patched 0.92
|
||||
/app/bin/myapp modified vanilla 0.98
|
||||
|
||||
Summary: 156 binaries analyzed, 3 modified, 153 unchanged
|
||||
```
|
||||
|
||||
### JSON Output
|
||||
|
||||
```bash
|
||||
stella scan diff \
|
||||
--base docker://registry.example.com/myapp:1.0.0 \
|
||||
--target docker://registry.example.com/myapp:1.0.1 \
|
||||
--format=json > diff.json
|
||||
```
|
||||
|
||||
The JSON output contains detailed section-level information. See [sample-outputs/diff.json](./sample-outputs/diff.json) for a complete example.
|
||||
|
||||
### Summary Output
|
||||
|
||||
```bash
|
||||
stella scan diff \
|
||||
--base docker://registry.example.com/myapp:1.0.0 \
|
||||
--target docker://registry.example.com/myapp:1.0.1 \
|
||||
--format=summary
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Binary Diff Summary
|
||||
-------------------
|
||||
Base: docker://registry.example.com/myapp:1.0.0 (sha256:abc123...)
|
||||
Target: docker://registry.example.com/myapp:1.0.1 (sha256:def456...)
|
||||
Platform: linux/amd64
|
||||
|
||||
Binaries: 156 total, 3 modified, 153 unchanged
|
||||
Verdicts: 2 patched, 1 vanilla
|
||||
```
|
||||
|
||||
## Using Digest References
|
||||
|
||||
For immutable references, use digests instead of tags:
|
||||
|
||||
```bash
|
||||
stella scan diff \
|
||||
--base docker://registry.example.com/myapp@sha256:abc123... \
|
||||
--target docker://registry.example.com/myapp@sha256:def456...
|
||||
```
|
||||
|
||||
## Filtering by Platform
|
||||
|
||||
For multi-arch images, specify the platform:
|
||||
|
||||
```bash
|
||||
# Linux AMD64 only
|
||||
stella scan diff \
|
||||
--base myapp:1.0.0 \
|
||||
--target myapp:1.0.1 \
|
||||
--platform=linux/amd64
|
||||
|
||||
# Linux ARM64
|
||||
stella scan diff \
|
||||
--base myapp:1.0.0 \
|
||||
--target myapp:1.0.1 \
|
||||
--platform=linux/arm64
|
||||
```
|
||||
|
||||
## Including Unchanged Binaries
|
||||
|
||||
By default, unchanged binaries are excluded from output. To include them:
|
||||
|
||||
```bash
|
||||
stella scan diff \
|
||||
--base myapp:1.0.0 \
|
||||
--target myapp:1.0.1 \
|
||||
--include-unchanged
|
||||
```
|
||||
|
||||
## Verbose Output
|
||||
|
||||
For debugging or detailed progress:
|
||||
|
||||
```bash
|
||||
stella scan diff \
|
||||
--base myapp:1.0.0 \
|
||||
--target myapp:1.0.1 \
|
||||
--verbose
|
||||
```
|
||||
|
||||
Output includes:
|
||||
- Layer download progress
|
||||
- Binary detection details
|
||||
- Section hash computation progress
|
||||
|
||||
## Understanding Verdicts
|
||||
|
||||
| Verdict | Meaning | Action |
|
||||
|---------|---------|--------|
|
||||
| `patched` | High confidence that a security patch was applied | Review changelog, consider safe to upgrade |
|
||||
| `vanilla` | Standard code change, no backport evidence | Normal release update |
|
||||
| `unknown` | Cannot determine patch status | Manual review recommended |
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Generate DSSE Attestations](./dsse-attestation.md) for audit trail
|
||||
- [Integrate with Policy](./policy-integration.md) for automated gates
|
||||
- [Add to CI/CD](./ci-cd-integration.md) for continuous verification
|
||||
371
docs/examples/binary-diff/ci-cd-integration.md
Normal file
371
docs/examples/binary-diff/ci-cd-integration.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# CI/CD Integration
|
||||
|
||||
This example demonstrates how to integrate binary diff attestation into your CI/CD pipelines.
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
### Basic Workflow
|
||||
|
||||
```yaml
|
||||
# .github/workflows/binary-diff.yml
|
||||
name: Binary Diff Attestation
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
binary-diff:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # For keyless signing
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Stella CLI
|
||||
uses: stellaops/setup-stella@v1
|
||||
with:
|
||||
version: 'latest'
|
||||
|
||||
- name: Login to Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev-tag
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Binary Diff
|
||||
if: steps.prev-tag.outputs.tag != ''
|
||||
run: |
|
||||
stella scan diff \
|
||||
--base ghcr.io/${{ github.repository }}:${{ steps.prev-tag.outputs.tag }} \
|
||||
--target ghcr.io/${{ github.repository }}:${{ github.ref_name }} \
|
||||
--mode=elf \
|
||||
--emit-dsse=./attestations/ \
|
||||
--format=json > diff.json
|
||||
|
||||
- name: Upload Attestations
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-diff-attestations
|
||||
path: |
|
||||
attestations/
|
||||
diff.json
|
||||
|
||||
- name: Attach Attestation to Image
|
||||
run: |
|
||||
# Using cosign to attach attestation
|
||||
cosign attach attestation \
|
||||
--attestation ./attestations/linux-amd64-binarydiff.dsse.json \
|
||||
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
```
|
||||
|
||||
### With Release Gate
|
||||
|
||||
```yaml
|
||||
# .github/workflows/release-gate.yml
|
||||
name: Release Gate with Binary Diff
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base_version:
|
||||
description: 'Base version to compare'
|
||||
required: true
|
||||
target_version:
|
||||
description: 'Target version to release'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
binary-diff-gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
verdict: ${{ steps.analyze.outputs.verdict }}
|
||||
|
||||
steps:
|
||||
- name: Setup Stella CLI
|
||||
uses: stellaops/setup-stella@v1
|
||||
|
||||
- name: Binary Diff Analysis
|
||||
id: diff
|
||||
run: |
|
||||
stella scan diff \
|
||||
--base myapp:${{ inputs.base_version }} \
|
||||
--target myapp:${{ inputs.target_version }} \
|
||||
--format=json > diff.json
|
||||
|
||||
- name: Analyze Results
|
||||
id: analyze
|
||||
run: |
|
||||
# Check for unknown verdicts
|
||||
UNKNOWN_COUNT=$(jq '.summary.verdicts.unknown // 0' diff.json)
|
||||
if [ "$UNKNOWN_COUNT" -gt "0" ]; then
|
||||
echo "verdict=review-required" >> $GITHUB_OUTPUT
|
||||
echo "::warning::Found $UNKNOWN_COUNT binaries with unknown verdicts"
|
||||
else
|
||||
echo "verdict=approved" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Gate Decision
|
||||
if: steps.analyze.outputs.verdict == 'review-required'
|
||||
run: |
|
||||
echo "Manual review required for unknown binary changes"
|
||||
exit 1
|
||||
```
|
||||
|
||||
## GitLab CI
|
||||
|
||||
### Basic Pipeline
|
||||
|
||||
```yaml
|
||||
# .gitlab-ci.yml
|
||||
stages:
|
||||
- build
|
||||
- analyze
|
||||
- release
|
||||
|
||||
variables:
|
||||
STELLA_VERSION: "latest"
|
||||
|
||||
binary-diff:
|
||||
stage: analyze
|
||||
image: stellaops/cli:${STELLA_VERSION}
|
||||
script:
|
||||
- |
|
||||
# Get previous tag
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
stella scan diff \
|
||||
--base ${CI_REGISTRY_IMAGE}:${PREV_TAG} \
|
||||
--target ${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG} \
|
||||
--mode=elf \
|
||||
--emit-dsse=attestations/ \
|
||||
--format=json > diff.json
|
||||
|
||||
# Upload to GitLab artifacts
|
||||
echo "Binary diff completed"
|
||||
else
|
||||
echo "No previous tag found, skipping diff"
|
||||
fi
|
||||
artifacts:
|
||||
paths:
|
||||
- attestations/
|
||||
- diff.json
|
||||
expire_in: 30 days
|
||||
only:
|
||||
- tags
|
||||
```
|
||||
|
||||
### With Security Gate
|
||||
|
||||
```yaml
|
||||
# .gitlab-ci.yml
|
||||
security-gate:
|
||||
stage: analyze
|
||||
image: stellaops/cli:latest
|
||||
script:
|
||||
- |
|
||||
stella scan diff \
|
||||
--base ${CI_REGISTRY_IMAGE}:${BASE_VERSION} \
|
||||
--target ${CI_REGISTRY_IMAGE}:${TARGET_VERSION} \
|
||||
--format=json > diff.json
|
||||
|
||||
# Fail if any unknown verdicts
|
||||
UNKNOWN=$(jq '.summary.verdicts.unknown // 0' diff.json)
|
||||
if [ "$UNKNOWN" -gt "0" ]; then
|
||||
echo "Security gate failed: $UNKNOWN unknown binary changes"
|
||||
jq '.findings[] | select(.verdict == "unknown")' diff.json
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Security gate passed"
|
||||
allow_failure: false
|
||||
```
|
||||
|
||||
## Jenkins Pipeline
|
||||
|
||||
```groovy
|
||||
// Jenkinsfile
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
STELLA_VERSION = 'latest'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Binary Diff') {
|
||||
steps {
|
||||
script {
|
||||
def prevTag = sh(
|
||||
script: 'git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo ""',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
if (prevTag) {
|
||||
sh """
|
||||
stella scan diff \\
|
||||
--base ${REGISTRY}/${IMAGE}:${prevTag} \\
|
||||
--target ${REGISTRY}/${IMAGE}:${TAG} \\
|
||||
--mode=elf \\
|
||||
--emit-dsse=attestations/ \\
|
||||
--format=json > diff.json
|
||||
"""
|
||||
|
||||
archiveArtifacts artifacts: 'attestations/*, diff.json'
|
||||
|
||||
// Parse and check results
|
||||
def diff = readJSON file: 'diff.json'
|
||||
if (diff.summary.verdicts.unknown > 0) {
|
||||
unstable("Found ${diff.summary.verdicts.unknown} unknown binary changes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Azure DevOps
|
||||
|
||||
```yaml
|
||||
# azure-pipelines.yml
|
||||
trigger:
|
||||
tags:
|
||||
include:
|
||||
- v*
|
||||
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
|
||||
steps:
|
||||
- task: Bash@3
|
||||
displayName: 'Install Stella CLI'
|
||||
inputs:
|
||||
targetType: 'inline'
|
||||
script: |
|
||||
curl -sSL https://get.stellaops.io | sh
|
||||
stella --version
|
||||
|
||||
- task: Docker@2
|
||||
displayName: 'Login to Registry'
|
||||
inputs:
|
||||
containerRegistry: 'myRegistry'
|
||||
command: 'login'
|
||||
|
||||
- task: Bash@3
|
||||
displayName: 'Binary Diff'
|
||||
inputs:
|
||||
targetType: 'inline'
|
||||
script: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
stella scan diff \
|
||||
--base $(REGISTRY)/$(IMAGE):${PREV_TAG} \
|
||||
--target $(REGISTRY)/$(IMAGE):$(Build.SourceBranchName) \
|
||||
--mode=elf \
|
||||
--emit-dsse=$(Build.ArtifactStagingDirectory)/attestations/ \
|
||||
--format=json > $(Build.ArtifactStagingDirectory)/diff.json
|
||||
fi
|
||||
|
||||
- task: PublishBuildArtifacts@1
|
||||
inputs:
|
||||
pathToPublish: '$(Build.ArtifactStagingDirectory)'
|
||||
artifactName: 'binary-diff'
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Use Digest References in Production
|
||||
|
||||
```bash
|
||||
# Instead of tags
|
||||
stella scan diff --base myapp:v1.0.0 --target myapp:v1.0.1
|
||||
|
||||
# Use digests for immutability
|
||||
stella scan diff \
|
||||
--base myapp@sha256:abc123... \
|
||||
--target myapp@sha256:def456...
|
||||
```
|
||||
|
||||
### 2. Store Attestations with Releases
|
||||
|
||||
Attach DSSE attestations to your container images or store them alongside release artifacts.
|
||||
|
||||
### 3. Set Appropriate Timeouts
|
||||
|
||||
```bash
|
||||
# For large images, increase timeout
|
||||
stella scan diff \
|
||||
--base myapp:v1 \
|
||||
--target myapp:v2 \
|
||||
--timeout=600
|
||||
```
|
||||
|
||||
### 4. Use Caching
|
||||
|
||||
```yaml
|
||||
# GitHub Actions with caching
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.stella/cache
|
||||
key: stella-${{ runner.os }}-${{ hashFiles('**/Dockerfile') }}
|
||||
```
|
||||
|
||||
### 5. Fail Fast on Critical Issues
|
||||
|
||||
```bash
|
||||
# Exit code indicates issues
|
||||
stella scan diff --base old --target new --format=json > diff.json
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Diff failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for critical verdicts
|
||||
jq -e '.summary.verdicts.unknown == 0' diff.json || exit 1
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Registry Authentication
|
||||
|
||||
```bash
|
||||
# Use Docker config
|
||||
stella scan diff \
|
||||
--base myapp:v1 \
|
||||
--target myapp:v2 \
|
||||
--registry-auth=~/.docker/config.json
|
||||
```
|
||||
|
||||
### Platform Issues
|
||||
|
||||
```bash
|
||||
# Explicitly specify platform for multi-arch
|
||||
stella scan diff \
|
||||
--base myapp:v1 \
|
||||
--target myapp:v2 \
|
||||
--platform=linux/amd64
|
||||
```
|
||||
|
||||
### Timeout Issues
|
||||
|
||||
```bash
|
||||
# Increase timeout for slow registries
|
||||
stella scan diff \
|
||||
--base myapp:v1 \
|
||||
--target myapp:v2 \
|
||||
--timeout=900
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"payloadType": "stellaops.binarydiff.v1",
|
||||
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiZG9ja2VyOi8vcmVnaXN0cnkuZXhhbXBsZS5jb20vYXBwQHNoYTI1NjpkZWY0NTZhYmM3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1NjdlZmdoIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImRlZjQ1NmFiYzc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2N2VmZ2gifX1dLCJwcmVkaWNhdGVUeXBlIjoic3RlbGxhb3BzLmJpbmFyeWRpZmYudjEiLCJwcmVkaWNhdGUiOnsiaW5wdXRzIjp7ImJhc2UiOnsiZGlnZXN0Ijoic2hhMjU2OmFiYzEyM2RlZjQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNGFiY2QifSwidGFyZ2V0Ijp7ImRpZ2VzdCI6InNoYTI1NjpkZWY0NTZhYmM3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1NjdlZmdoIn19LCJmaW5kaW5ncyI6W3sicGF0aCI6Ii91c3IvbGliL2xpYnNzbC5zby4zIiwiY2hhbmdlVHlwZSI6Im1vZGlmaWVkIiwidmVyZGljdCI6InBhdGNoZWQiLCJjb25maWRlbmNlIjowLjk1fV0sIm1ldGFkYXRhIjp7InRvb2xWZXJzaW9uIjoiMS4wLjAiLCJhbmFseXNpc1RpbWVzdGFtcCI6IjIwMjYtMDEtMTNUMTI6MDA6MDBaIn19fQ==",
|
||||
"signatures": [
|
||||
{
|
||||
"keyid": "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA",
|
||||
"sig": "MEUCIQDKZokqnCjrRtw5EXP14JvsBwFDRPfCp9K0UoOlWGdlDQIgSNpOGPqKNLv5MNZLYc5iE7q5b3wW6K0cDpjNxBxCWdU="
|
||||
}
|
||||
],
|
||||
"_note": "This is a sample DSSE envelope for documentation purposes. The payload is base64-encoded and contains an in-toto statement with a BinaryDiffV1 predicate. In production, the signature would be cryptographically valid.",
|
||||
"_rekorMetadata": {
|
||||
"logIndex": 12345678,
|
||||
"entryUuid": "24296fb24b8ad77aa3e6b0d1b6e0e3a0c9f8d7e6b5a4c3d2e1f0a9b8c7d6e5f4",
|
||||
"integratedTime": "2026-01-13T12:00:05Z",
|
||||
"logUrl": "https://rekor.sigstore.dev"
|
||||
}
|
||||
}
|
||||
27
docs/examples/binary-diff/sample-outputs/diff-table.txt
Normal file
27
docs/examples/binary-diff/sample-outputs/diff-table.txt
Normal file
@@ -0,0 +1,27 @@
|
||||
Binary Diff: docker://registry.example.com/app:1.0.0 -> docker://registry.example.com/app:1.0.1
|
||||
Platform: linux/amd64
|
||||
Analysis Mode: ELF Section Hashes
|
||||
Analyzed Sections: .text, .rodata, .data, .symtab, .dynsym
|
||||
|
||||
PATH CHANGE VERDICT CONFIDENCE SECTIONS CHANGED
|
||||
--------------------------------------------------------------------------------------------------
|
||||
/usr/lib/x86_64-linux-gnu/libssl.so.3 modified patched 0.95 .text, .rodata
|
||||
/usr/lib/x86_64-linux-gnu/libcrypto.so.3 modified patched 0.92 .text
|
||||
/usr/bin/openssl modified unknown 0.75 .text, .data, .symtab
|
||||
/lib/x86_64-linux-gnu/libc.so.6 unchanged - - -
|
||||
/lib/x86_64-linux-gnu/libpthread.so.0 unchanged - - -
|
||||
/usr/lib/x86_64-linux-gnu/libz.so.1 unchanged - - -
|
||||
/app/bin/myapp modified vanilla 0.98 .text, .rodata, .data
|
||||
|
||||
Summary
|
||||
-------
|
||||
Total binaries analyzed: 156
|
||||
Modified: 4
|
||||
Unchanged: 152
|
||||
|
||||
Verdicts:
|
||||
Patched: 2 (high confidence backport detected)
|
||||
Vanilla: 1 (standard update, no backport evidence)
|
||||
Unknown: 1 (insufficient evidence for classification)
|
||||
|
||||
Analysis completed in 12.4s
|
||||
179
docs/examples/binary-diff/sample-outputs/diff.json
Normal file
179
docs/examples/binary-diff/sample-outputs/diff.json
Normal file
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"base": {
|
||||
"reference": "docker://registry.example.com/app:1.0.0",
|
||||
"digest": "sha256:abc123def456789012345678901234567890123456789012345678901234abcd",
|
||||
"manifestDigest": "sha256:111222333444555666777888999000aaabbbcccdddeeefff000111222333444555"
|
||||
},
|
||||
"target": {
|
||||
"reference": "docker://registry.example.com/app:1.0.1",
|
||||
"digest": "sha256:def456abc789012345678901234567890123456789012345678901234567efgh",
|
||||
"manifestDigest": "sha256:666777888999000aaabbbcccdddeeefff000111222333444555666777888999000"
|
||||
},
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"architecture": "amd64"
|
||||
},
|
||||
"analysisMode": "elf",
|
||||
"timestamp": "2026-01-13T12:00:00.000000Z",
|
||||
"findings": [
|
||||
{
|
||||
"path": "/usr/lib/x86_64-linux-gnu/libssl.so.3",
|
||||
"changeType": "modified",
|
||||
"binaryFormat": "elf",
|
||||
"layerDigest": "sha256:aaa111bbb222ccc333ddd444eee555fff666777888999000aaabbbcccdddeeef",
|
||||
"baseHashes": {
|
||||
"buildId": "abc123def456789012345678",
|
||||
"fileHash": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"sections": {
|
||||
".text": {
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"size": 524288,
|
||||
"offset": 4096
|
||||
},
|
||||
".rodata": {
|
||||
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"size": 131072,
|
||||
"offset": 528384
|
||||
}
|
||||
}
|
||||
},
|
||||
"targetHashes": {
|
||||
"buildId": "def789abc012345678901234",
|
||||
"fileHash": "2222222222222222222222222222222222222222222222222222222222222222",
|
||||
"sections": {
|
||||
".text": {
|
||||
"sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"size": 524544,
|
||||
"offset": 4096
|
||||
},
|
||||
".rodata": {
|
||||
"sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"size": 131200,
|
||||
"offset": 528640
|
||||
}
|
||||
}
|
||||
},
|
||||
"sectionDeltas": [
|
||||
{
|
||||
"section": ".text",
|
||||
"status": "modified",
|
||||
"baseSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"targetSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"sizeDelta": 256
|
||||
},
|
||||
{
|
||||
"section": ".rodata",
|
||||
"status": "modified",
|
||||
"baseSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"targetSha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"sizeDelta": 128
|
||||
},
|
||||
{
|
||||
"section": ".data",
|
||||
"status": "identical",
|
||||
"baseSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"targetSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"sizeDelta": 0
|
||||
},
|
||||
{
|
||||
"section": ".symtab",
|
||||
"status": "identical",
|
||||
"baseSha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
"targetSha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
"sizeDelta": 0
|
||||
}
|
||||
],
|
||||
"confidence": 0.95,
|
||||
"verdict": "patched"
|
||||
},
|
||||
{
|
||||
"path": "/usr/lib/x86_64-linux-gnu/libcrypto.so.3",
|
||||
"changeType": "modified",
|
||||
"binaryFormat": "elf",
|
||||
"layerDigest": "sha256:aaa111bbb222ccc333ddd444eee555fff666777888999000aaabbbcccdddeeef",
|
||||
"sectionDeltas": [
|
||||
{
|
||||
"section": ".text",
|
||||
"status": "modified",
|
||||
"sizeDelta": 1024
|
||||
},
|
||||
{
|
||||
"section": ".rodata",
|
||||
"status": "identical",
|
||||
"sizeDelta": 0
|
||||
}
|
||||
],
|
||||
"confidence": 0.92,
|
||||
"verdict": "patched"
|
||||
},
|
||||
{
|
||||
"path": "/usr/bin/openssl",
|
||||
"changeType": "modified",
|
||||
"binaryFormat": "elf",
|
||||
"sectionDeltas": [
|
||||
{
|
||||
"section": ".text",
|
||||
"status": "modified",
|
||||
"sizeDelta": 512
|
||||
},
|
||||
{
|
||||
"section": ".data",
|
||||
"status": "modified",
|
||||
"sizeDelta": 64
|
||||
},
|
||||
{
|
||||
"section": ".symtab",
|
||||
"status": "modified",
|
||||
"sizeDelta": 128
|
||||
}
|
||||
],
|
||||
"confidence": 0.75,
|
||||
"verdict": "unknown"
|
||||
},
|
||||
{
|
||||
"path": "/app/bin/myapp",
|
||||
"changeType": "modified",
|
||||
"binaryFormat": "elf",
|
||||
"sectionDeltas": [
|
||||
{
|
||||
"section": ".text",
|
||||
"status": "modified",
|
||||
"sizeDelta": 2048
|
||||
},
|
||||
{
|
||||
"section": ".rodata",
|
||||
"status": "modified",
|
||||
"sizeDelta": 512
|
||||
},
|
||||
{
|
||||
"section": ".data",
|
||||
"status": "modified",
|
||||
"sizeDelta": 128
|
||||
}
|
||||
],
|
||||
"confidence": 0.98,
|
||||
"verdict": "vanilla"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totalBinaries": 156,
|
||||
"modified": 4,
|
||||
"unchanged": 152,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"verdicts": {
|
||||
"patched": 2,
|
||||
"vanilla": 1,
|
||||
"unknown": 1,
|
||||
"incompatible": 0
|
||||
},
|
||||
"sectionsAnalyzed": [".text", ".rodata", ".data", ".symtab", ".dynsym"],
|
||||
"analysisDurationMs": 12400
|
||||
},
|
||||
"metadata": {
|
||||
"toolVersion": "1.0.0",
|
||||
"analysisTimestamp": "2026-01-13T12:00:00.000000Z",
|
||||
"configDigest": "sha256:config123456789abcdef0123456789abcdef0123456789abcdef0123456789ab"
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
# SPRINT INDEX: Doctor Diagnostics System
|
||||
|
||||
> **Implementation ID:** 20260112
|
||||
> **Batch ID:** 001
|
||||
> **Phase:** Self-Service Diagnostics
|
||||
> **Status:** DONE
|
||||
> **Created:** 12-Jan-2026
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implement a comprehensive **Doctor Diagnostics System** that enables self-service troubleshooting for Stella Ops deployments. This addresses the critical need for operators, DevOps engineers, and developers to diagnose, understand, and remediate issues without requiring deep platform knowledge or documentation familiarity.
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Today's health check infrastructure is fragmented across 20+ services with inconsistent interfaces, no unified CLI entry point, and no actionable remediation guidance. Users cannot easily:
|
||||
|
||||
1. Diagnose what is working vs. what is failing
|
||||
2. Understand why failures occur (evidence collection)
|
||||
3. Fix issues without reading extensive documentation
|
||||
4. Verify fixes with re-runnable checks
|
||||
|
||||
### Key Capabilities
|
||||
|
||||
1. **Unified Doctor Engine** - Plugin-based check execution with parallel processing
|
||||
2. **48+ Diagnostic Checks** - Covering core, database, services, security, integrations, observability
|
||||
3. **CLI Surface** - `stella doctor` command with rich filtering and output formats
|
||||
4. **UI Surface** - Interactive doctor dashboard at `/ops/doctor`
|
||||
5. **API Surface** - Programmatic access for CI/CD and monitoring integration
|
||||
6. **Actionable Remediation** - Copy/paste fix commands with verification steps
|
||||
|
||||
### Architecture Decision
|
||||
|
||||
**Consolidate existing infrastructure, extend with plugin system:**
|
||||
|
||||
- Leverage existing `HealthCheckResult` from `StellaOps.Plugin.Abstractions`
|
||||
- Extend existing `IDoctorCheck` from ReleaseOrchestrator IntegrationHub
|
||||
- Leverage existing `IMigrationRunner` for database migration checks
|
||||
- Reuse existing health endpoints for service graph checks
|
||||
- Create new plugin discovery and execution framework
|
||||
|
||||
---
|
||||
|
||||
## Consolidation Strategy
|
||||
|
||||
### Phase 1: Foundation Consolidation
|
||||
|
||||
| Existing Component | Location | Action |
|
||||
|-------------------|----------|--------|
|
||||
| IDoctorCheck | IntegrationHub/Doctor | **Extend** - Add evidence and remediation |
|
||||
| HealthCheckResult | Plugin.Abstractions | **Reuse** - Map to DoctorSeverity |
|
||||
| DoctorReport | IntegrationHub/Doctor | **Extend** - Add remediation aggregation |
|
||||
| IMigrationRunner | Infrastructure.Postgres | **Integrate** - Wrap in database plugin |
|
||||
| CryptoProfileValidator | Cli/Services | **Migrate** - Move to core plugin |
|
||||
| PlatformHealthService | Platform.Health | **Integrate** - Wire into service graph plugin |
|
||||
|
||||
### Phase 2: Plugin Implementation
|
||||
|
||||
| Plugin | Checks | Priority | Notes |
|
||||
|--------|--------|----------|-------|
|
||||
| Core | 9 | P0 | Config, runtime, disk, memory, time, crypto |
|
||||
| Database | 8 | P0 | Connectivity, migrations, schema, pool |
|
||||
| ServiceGraph | 6 | P1 | Gateway, routing, service health |
|
||||
| Security | 9 | P1 | OIDC, LDAP, TLS, Vault |
|
||||
| Integration.SCM | 8 | P2 | GitHub, GitLab connectivity/auth/permissions |
|
||||
| Integration.Registry | 6 | P2 | Harbor, ECR connectivity/auth/pull |
|
||||
| Observability | 4 | P3 | OTLP, logs, metrics |
|
||||
| ReleaseOrchestrator | 4 | P3 | Environments, deployment targets |
|
||||
|
||||
### Phase 3: Surface Implementation
|
||||
|
||||
| Surface | Entry Point | Priority |
|
||||
|---------|-------------|----------|
|
||||
| CLI | `stella doctor` | P0 |
|
||||
| API | `/api/v1/doctor/*` | P1 |
|
||||
| UI | `/ops/doctor` | P2 |
|
||||
|
||||
---
|
||||
|
||||
## Sprint Structure
|
||||
|
||||
| Sprint | Module | Description | Status | Dependency |
|
||||
|--------|--------|-------------|--------|------------|
|
||||
| [001_001](SPRINT_20260112_001_001_DOCTOR_foundation.md) | LB | Doctor engine foundation and plugin framework | DONE | - |
|
||||
| [001_002](SPRINT_20260112_001_002_DOCTOR_core_plugin.md) | LB | Core platform plugin (9 checks) | DONE | 001_001 |
|
||||
| [001_003](SPRINT_20260112_001_003_DOCTOR_database_plugin.md) | LB | Database plugin (8 checks) | DONE | 001_001 |
|
||||
| [001_004](SPRINT_20260112_001_004_DOCTOR_service_security_plugins.md) | LB | Service graph + security plugins (15 checks) | DONE | 001_001 |
|
||||
| [001_005](SPRINT_20260112_001_005_DOCTOR_integration_plugins.md) | LB | SCM + registry plugins (14 checks) | DONE | 001_001 |
|
||||
| [001_006](SPRINT_20260112_001_006_CLI_doctor_command.md) | CLI | `stella doctor` command implementation | DONE | 001_002 |
|
||||
| [001_007](SPRINT_20260112_001_007_API_doctor_endpoints.md) | BE | Doctor API endpoints | DONE | 001_002 |
|
||||
| [001_008](SPRINT_20260112_001_008_FE_doctor_dashboard.md) | FE | Angular doctor dashboard | DONE | 001_007 |
|
||||
| [001_009](SPRINT_20260112_001_009_DOCTOR_self_service.md) | LB | Self-service features (export, scheduling) | DONE | 001_006 |
|
||||
|
||||
---
|
||||
|
||||
## Working Directory
|
||||
|
||||
```
|
||||
src/
|
||||
├── __Libraries/
|
||||
│ └── StellaOps.Doctor/ # NEW - Core doctor engine
|
||||
│ ├── Engine/
|
||||
│ │ ├── DoctorEngine.cs
|
||||
│ │ ├── CheckExecutor.cs
|
||||
│ │ ├── CheckRegistry.cs
|
||||
│ │ └── PluginLoader.cs
|
||||
│ ├── Models/
|
||||
│ │ ├── DoctorCheckResult.cs
|
||||
│ │ ├── DoctorReport.cs
|
||||
│ │ ├── Evidence.cs
|
||||
│ │ ├── Remediation.cs
|
||||
│ │ └── DoctorRunOptions.cs
|
||||
│ ├── Plugins/
|
||||
│ │ ├── IDoctorPlugin.cs
|
||||
│ │ ├── IDoctorCheck.cs
|
||||
│ │ ├── DoctorPluginContext.cs
|
||||
│ │ └── DoctorCategory.cs
|
||||
│ ├── Output/
|
||||
│ │ ├── IReportFormatter.cs
|
||||
│ │ ├── TextReportFormatter.cs
|
||||
│ │ ├── JsonReportFormatter.cs
|
||||
│ │ └── MarkdownReportFormatter.cs
|
||||
│ └── DI/
|
||||
│ └── DoctorServiceExtensions.cs
|
||||
├── Doctor/ # NEW - Doctor module
|
||||
│ └── __Plugins/
|
||||
│ ├── StellaOps.Doctor.Plugin.Core/ # Core platform checks
|
||||
│ ├── StellaOps.Doctor.Plugin.Database/ # Database checks
|
||||
│ ├── StellaOps.Doctor.Plugin.ServiceGraph/ # Service health checks
|
||||
│ ├── StellaOps.Doctor.Plugin.Security/ # Auth, TLS, secrets
|
||||
│ ├── StellaOps.Doctor.Plugin.Scm/ # SCM integrations
|
||||
│ ├── StellaOps.Doctor.Plugin.Registry/ # Registry integrations
|
||||
│ └── StellaOps.Doctor.Plugin.Observability/ # Telemetry checks
|
||||
│ └── StellaOps.Doctor.WebService/ # Doctor API host
|
||||
│ └── __Tests/
|
||||
│ └── StellaOps.Doctor.*.Tests/ # Test projects
|
||||
├── Cli/
|
||||
│ └── StellaOps.Cli/
|
||||
│ └── Commands/
|
||||
│ └── DoctorCommandGroup.cs # NEW
|
||||
├── Web/
|
||||
│ └── StellaOps.Web/
|
||||
│ └── src/app/features/
|
||||
│ └── doctor/ # NEW - Doctor UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Module | Status |
|
||||
|------------|--------|--------|
|
||||
| HealthCheckResult | Plugin.Abstractions | EXISTS |
|
||||
| IDoctorCheck (existing) | IntegrationHub | EXISTS - Extend |
|
||||
| IMigrationRunner | Infrastructure.Postgres | EXISTS |
|
||||
| IIdentityProviderPlugin | Authority.Plugins | EXISTS |
|
||||
| IIntegrationConnectorCapability | ReleaseOrchestrator.Plugin | EXISTS |
|
||||
| PlatformHealthService | Platform.Health | EXISTS |
|
||||
| CommandGroup pattern | Cli | EXISTS |
|
||||
| Angular features pattern | Web | EXISTS |
|
||||
|
||||
---
|
||||
|
||||
## Check Catalog Summary
|
||||
|
||||
### Total: 48 Checks
|
||||
|
||||
| Category | Plugin | Check Count | Priority |
|
||||
|----------|--------|-------------|----------|
|
||||
| Core | stellaops.doctor.core | 9 | P0 |
|
||||
| Database | stellaops.doctor.database | 8 | P0 |
|
||||
| ServiceGraph | stellaops.doctor.servicegraph | 6 | P1 |
|
||||
| Security | stellaops.doctor.security | 9 | P1 |
|
||||
| Integration.SCM | stellaops.doctor.scm.* | 8 | P2 |
|
||||
| Integration.Registry | stellaops.doctor.registry.* | 6 | P2 |
|
||||
| Observability | stellaops.doctor.observability | 4 | P3 |
|
||||
|
||||
### Check ID Convention
|
||||
|
||||
```
|
||||
check.{category}.{subcategory}.{specific}
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `check.config.required`
|
||||
- `check.database.migrations.pending`
|
||||
- `check.services.gateway.routing`
|
||||
- `check.integration.scm.github.auth`
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] Doctor engine executes 48+ checks with parallel processing
|
||||
- [x] All checks produce evidence and remediation commands
|
||||
- [x] `stella doctor` CLI command with all filter options
|
||||
- [x] JSON/Markdown/Text output formats
|
||||
- [x] API endpoints for programmatic access
|
||||
- [x] UI dashboard with real-time updates
|
||||
- [x] Export capability for support tickets
|
||||
- [x] Unit test coverage >= 85%
|
||||
- [x] Integration tests for all plugins
|
||||
- [ ] Documentation in `docs/doctor/` (TODO)
|
||||
|
||||
---
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | All checks passed |
|
||||
| 1 | One or more warnings |
|
||||
| 2 | One or more failures |
|
||||
| 3 | Doctor engine error |
|
||||
| 4 | Invalid arguments |
|
||||
| 5 | Timeout exceeded |
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Secret Redaction** - Connection strings, tokens, passwords never appear in output
|
||||
2. **RBAC Scopes** - `doctor:run`, `doctor:run:full`, `doctor:export`, `admin:system`
|
||||
3. **Audit Logging** - All doctor runs logged with user context
|
||||
4. **Sensitive Checks** - Some checks require elevated permissions
|
||||
|
||||
---
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
| Decision/Risk | Status | Notes |
|
||||
|---------------|--------|-------|
|
||||
| Consolidate vs. replace existing health | DECIDED | Consolidate - reuse existing infrastructure |
|
||||
| Plugin discovery: static vs dynamic | DECIDED | Static (DI registration) with optional dynamic loading |
|
||||
| Check timeout handling | DECIDED | Per-check timeout with graceful cancellation |
|
||||
| Remediation command safety | MITIGATED | Safety notes for destructive operations, backup recommendations |
|
||||
| Multi-tenant check isolation | DEFERRED | Phase 2 - tenant-scoped checks |
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date | Entry |
|
||||
|------|-------|
|
||||
| 12-Jan-2026 | Sprint created from doctor-capabilities.md specification |
|
||||
| 12-Jan-2026 | Consolidation strategy defined based on codebase analysis |
|
||||
| 12-Jan-2026 | Sprint 001_008 (FE Dashboard) completed - Angular 17+ standalone components |
|
||||
| 12-Jan-2026 | Sprint 001_009 (Self-service) completed - Export, Observability plugin |
|
||||
| 12-Jan-2026 | All 9 sprints complete - Doctor Diagnostics System fully implemented |
|
||||
|
||||
---
|
||||
|
||||
## Reference Documents
|
||||
|
||||
- **Specification:** `docs/doctor/doctor-capabilities.md`
|
||||
- **Existing Doctor Service:** `src/ReleaseOrchestrator/__Libraries/.../IntegrationHub/Doctor/`
|
||||
- **Health Abstractions:** `src/Plugin/StellaOps.Plugin.Abstractions/Health/`
|
||||
- **Migration Framework:** `src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/`
|
||||
- **Authority Plugins:** `src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,597 +0,0 @@
|
||||
# SPRINT: Doctor Core Plugin - Platform and Runtime Checks
|
||||
|
||||
> **Implementation ID:** 20260112
|
||||
> **Sprint ID:** 001_002
|
||||
> **Module:** LB (Library)
|
||||
> **Status:** DONE
|
||||
> **Created:** 12-Jan-2026
|
||||
> **Depends On:** 001_001
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implement the Core Platform plugin providing 9 fundamental diagnostic checks for configuration, runtime environment, and system resources.
|
||||
|
||||
---
|
||||
|
||||
## Working Directory
|
||||
|
||||
```
|
||||
src/Doctor/__Plugins/StellaOps.Doctor.Plugin.Core/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Check Catalog
|
||||
|
||||
| CheckId | Name | Severity | Tags | Description |
|
||||
|---------|------|----------|------|-------------|
|
||||
| `check.config.required` | Required Config | Fail | quick, config, startup | All required configuration values present |
|
||||
| `check.config.syntax` | Config Syntax | Fail | quick, config | Configuration files have valid YAML/JSON |
|
||||
| `check.config.deprecated` | Deprecated Config | Warn | config | No deprecated configuration keys in use |
|
||||
| `check.runtime.dotnet` | .NET Runtime | Fail | quick, runtime | .NET version meets minimum requirements |
|
||||
| `check.runtime.memory` | Memory | Warn | runtime, resources | Sufficient memory available |
|
||||
| `check.runtime.disk.space` | Disk Space | Warn | runtime, resources | Sufficient disk space on required paths |
|
||||
| `check.runtime.disk.permissions` | Disk Permissions | Fail | quick, runtime, security | Write permissions on required directories |
|
||||
| `check.time.sync` | Time Sync | Warn | quick, runtime | System clock is synchronized (NTP) |
|
||||
| `check.crypto.profiles` | Crypto Profiles | Fail | quick, security, crypto | Crypto profile valid, providers available |
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
### Task 1: Plugin Structure
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```
|
||||
StellaOps.Doctor.Plugin.Core/
|
||||
├── CoreDoctorPlugin.cs
|
||||
├── Checks/
|
||||
│ ├── RequiredConfigCheck.cs
|
||||
│ ├── ConfigSyntaxCheck.cs
|
||||
│ ├── DeprecatedConfigCheck.cs
|
||||
│ ├── DotNetRuntimeCheck.cs
|
||||
│ ├── MemoryCheck.cs
|
||||
│ ├── DiskSpaceCheck.cs
|
||||
│ ├── DiskPermissionsCheck.cs
|
||||
│ ├── TimeSyncCheck.cs
|
||||
│ └── CryptoProfilesCheck.cs
|
||||
├── Configuration/
|
||||
│ ├── RequiredConfigKeys.cs
|
||||
│ ├── DeprecatedConfigMapping.cs
|
||||
│ └── ResourceThresholds.cs
|
||||
└── StellaOps.Doctor.Plugin.Core.csproj
|
||||
```
|
||||
|
||||
**CoreDoctorPlugin:**
|
||||
|
||||
```csharp
|
||||
public sealed class CoreDoctorPlugin : IDoctorPlugin
|
||||
{
|
||||
public string PluginId => "stellaops.doctor.core";
|
||||
public string DisplayName => "Core Platform";
|
||||
public DoctorCategory Category => DoctorCategory.Core;
|
||||
public Version Version => new(1, 0, 0);
|
||||
public Version MinEngineVersion => new(1, 0, 0);
|
||||
|
||||
private readonly IReadOnlyList<IDoctorCheck> _checks;
|
||||
|
||||
public CoreDoctorPlugin()
|
||||
{
|
||||
_checks = new IDoctorCheck[]
|
||||
{
|
||||
new RequiredConfigCheck(),
|
||||
new ConfigSyntaxCheck(),
|
||||
new DeprecatedConfigCheck(),
|
||||
new DotNetRuntimeCheck(),
|
||||
new MemoryCheck(),
|
||||
new DiskSpaceCheck(),
|
||||
new DiskPermissionsCheck(),
|
||||
new TimeSyncCheck(),
|
||||
new CryptoProfilesCheck()
|
||||
};
|
||||
}
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context) => _checks;
|
||||
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: check.config.required
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Verify all required configuration values are present.
|
||||
|
||||
```csharp
|
||||
public sealed class RequiredConfigCheck : IDoctorCheck
|
||||
{
|
||||
public string CheckId => "check.config.required";
|
||||
public string Name => "Required Configuration";
|
||||
public string Description => "Verify all required configuration values are present";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
public IReadOnlyList<string> Tags => ["quick", "config", "startup"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
private static readonly IReadOnlyList<RequiredConfigKey> RequiredKeys =
|
||||
[
|
||||
new("STELLAOPS_BACKEND_URL", "Backend API URL", "Environment or stellaops.yaml"),
|
||||
new("ConnectionStrings:StellaOps", "Database connection", "Environment or stellaops.yaml"),
|
||||
new("Authority:Issuer", "Authority issuer URL", "stellaops.yaml")
|
||||
];
|
||||
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var missing = new List<RequiredConfigKey>();
|
||||
var present = new List<string>();
|
||||
|
||||
foreach (var key in RequiredKeys)
|
||||
{
|
||||
var value = context.Configuration[key.Key];
|
||||
if (string.IsNullOrEmpty(value))
|
||||
missing.Add(key);
|
||||
else
|
||||
present.Add(key.Key);
|
||||
}
|
||||
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
return Task.FromResult(context.CreateResult(CheckId)
|
||||
.Pass($"All {RequiredKeys.Count} required configuration values are present")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("ConfiguredKeys", string.Join(", ", present))
|
||||
.Add("TotalRequired", RequiredKeys.Count))
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(context.CreateResult(CheckId)
|
||||
.Fail($"{missing.Count} required configuration value(s) missing")
|
||||
.WithEvidence(eb =>
|
||||
{
|
||||
eb.Add("MissingKeys", string.Join(", ", missing.Select(k => k.Key)));
|
||||
eb.Add("ConfiguredKeys", string.Join(", ", present));
|
||||
foreach (var key in missing)
|
||||
{
|
||||
eb.Add($"Missing.{key.Key}", $"{key.Description} (source: {key.Source})");
|
||||
}
|
||||
})
|
||||
.WithCauses(
|
||||
"Environment variables not set",
|
||||
"Configuration file not found or not loaded",
|
||||
"Configuration section missing from stellaops.yaml")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check which configuration values are missing",
|
||||
"stella config list --show-missing", CommandType.Shell)
|
||||
.AddStep(2, "Set missing environment variables",
|
||||
GenerateEnvExportCommands(missing), CommandType.Shell)
|
||||
.AddStep(3, "Or update configuration file",
|
||||
"# Edit: /etc/stellaops/stellaops.yaml", CommandType.FileEdit))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
private static string GenerateEnvExportCommands(List<RequiredConfigKey> missing)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var key in missing)
|
||||
{
|
||||
sb.AppendLine($"export {key.Key}=\"{{VALUE}}\"");
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RequiredConfigKey(string Key, string Description, string Source);
|
||||
```
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Checks all required keys
|
||||
- [ ] Evidence includes missing and present keys
|
||||
- [ ] Remediation generates export commands
|
||||
|
||||
---
|
||||
|
||||
### Task 3: check.config.syntax
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Verify configuration files have valid YAML/JSON syntax.
|
||||
|
||||
```csharp
|
||||
public sealed class ConfigSyntaxCheck : IDoctorCheck
|
||||
{
|
||||
public string CheckId => "check.config.syntax";
|
||||
public string Name => "Configuration Syntax";
|
||||
public string Description => "Verify configuration files have valid YAML/JSON syntax";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
public IReadOnlyList<string> Tags => ["quick", "config"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(200);
|
||||
|
||||
private static readonly string[] ConfigPaths =
|
||||
[
|
||||
"/etc/stellaops/stellaops.yaml",
|
||||
"/etc/stellaops/stellaops.json",
|
||||
"stellaops.yaml",
|
||||
"stellaops.json"
|
||||
];
|
||||
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var errors = new List<ConfigSyntaxError>();
|
||||
var validated = new List<string>();
|
||||
|
||||
foreach (var path in ConfigPaths)
|
||||
{
|
||||
if (!File.Exists(path)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(path);
|
||||
if (path.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".yml", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ValidateYaml(content);
|
||||
}
|
||||
else if (path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
JsonDocument.Parse(content);
|
||||
}
|
||||
validated.Add(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(new ConfigSyntaxError(path, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count == 0)
|
||||
{
|
||||
return Task.FromResult(context.CreateResult(CheckId)
|
||||
.Pass($"All configuration files have valid syntax ({validated.Count} files)")
|
||||
.WithEvidence(eb => eb.Add("ValidatedFiles", string.Join(", ", validated)))
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(context.CreateResult(CheckId)
|
||||
.Fail($"{errors.Count} configuration file(s) have syntax errors")
|
||||
.WithEvidence(eb =>
|
||||
{
|
||||
foreach (var error in errors)
|
||||
{
|
||||
eb.Add($"Error.{Path.GetFileName(error.Path)}", $"{error.Path}: {error.Message}");
|
||||
}
|
||||
})
|
||||
.WithCauses(
|
||||
"Invalid YAML indentation (tabs vs spaces)",
|
||||
"JSON syntax error (missing comma, bracket)",
|
||||
"File encoding issues (not UTF-8)")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Validate YAML syntax", "yamllint /etc/stellaops/stellaops.yaml", CommandType.Shell)
|
||||
.AddStep(2, "Check file encoding", "file /etc/stellaops/stellaops.yaml", CommandType.Shell)
|
||||
.AddStep(3, "Fix common issues", "# Use spaces not tabs, check string quoting", CommandType.Manual))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
private static void ValidateYaml(string content)
|
||||
{
|
||||
var deserializer = new YamlDotNet.Serialization.Deserializer();
|
||||
deserializer.Deserialize<object>(content);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ConfigSyntaxError(string Path, string Message);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: check.runtime.dotnet
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Verify .NET runtime version meets minimum requirements.
|
||||
|
||||
```csharp
|
||||
public sealed class DotNetRuntimeCheck : IDoctorCheck
|
||||
{
|
||||
public string CheckId => "check.runtime.dotnet";
|
||||
public string Name => ".NET Runtime Version";
|
||||
public string Description => "Verify .NET runtime version meets minimum requirements";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
public IReadOnlyList<string> Tags => ["quick", "runtime"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(50);
|
||||
|
||||
private static readonly Version MinimumVersion = new(10, 0, 0);
|
||||
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var currentVersion = Environment.Version;
|
||||
var runtimeInfo = RuntimeInformation.FrameworkDescription;
|
||||
|
||||
if (currentVersion >= MinimumVersion)
|
||||
{
|
||||
return Task.FromResult(context.CreateResult(CheckId)
|
||||
.Pass($".NET {currentVersion} meets minimum requirement ({MinimumVersion})")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("CurrentVersion", currentVersion.ToString())
|
||||
.Add("MinimumVersion", MinimumVersion.ToString())
|
||||
.Add("RuntimeDescription", runtimeInfo)
|
||||
.Add("RuntimePath", RuntimeEnvironment.GetRuntimeDirectory()))
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(context.CreateResult(CheckId)
|
||||
.Fail($".NET {currentVersion} is below minimum requirement ({MinimumVersion})")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("CurrentVersion", currentVersion.ToString())
|
||||
.Add("MinimumVersion", MinimumVersion.ToString())
|
||||
.Add("RuntimeDescription", runtimeInfo))
|
||||
.WithCauses(
|
||||
"Outdated .NET runtime installed",
|
||||
"Container image using old base",
|
||||
"System package not updated")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check current .NET version", "dotnet --version", CommandType.Shell)
|
||||
.AddStep(2, "Install required .NET version (Ubuntu/Debian)",
|
||||
"wget https://dot.net/v1/dotnet-install.sh && chmod +x dotnet-install.sh && ./dotnet-install.sh --channel 10.0",
|
||||
CommandType.Shell)
|
||||
.AddStep(3, "Verify installation", "dotnet --list-runtimes", CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: check.runtime.memory
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Check available memory.
|
||||
|
||||
```csharp
|
||||
public sealed class MemoryCheck : IDoctorCheck
|
||||
{
|
||||
public string CheckId => "check.runtime.memory";
|
||||
public string Name => "Available Memory";
|
||||
public string Description => "Verify sufficient memory is available for operation";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
public IReadOnlyList<string> Tags => ["runtime", "resources"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
private const long MinimumAvailableBytes = 1L * 1024 * 1024 * 1024; // 1 GB
|
||||
private const long WarningAvailableBytes = 2L * 1024 * 1024 * 1024; // 2 GB
|
||||
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var gcInfo = GC.GetGCMemoryInfo();
|
||||
var totalMemory = gcInfo.TotalAvailableMemoryBytes;
|
||||
var availableMemory = totalMemory - GC.GetTotalMemory(forceFullCollection: false);
|
||||
|
||||
var evidence = context.CreateEvidence()
|
||||
.Add("TotalMemory", FormatBytes(totalMemory))
|
||||
.Add("AvailableMemory", FormatBytes(availableMemory))
|
||||
.Add("GCHeapSize", FormatBytes(gcInfo.HeapSizeBytes))
|
||||
.Add("GCFragmentation", $"{gcInfo.FragmentedBytes * 100.0 / gcInfo.HeapSizeBytes:F1}%")
|
||||
.Build("Memory utilization metrics");
|
||||
|
||||
if (availableMemory < MinimumAvailableBytes)
|
||||
{
|
||||
return Task.FromResult(context.CreateResult(CheckId)
|
||||
.Fail($"Critical: Only {FormatBytes(availableMemory)} available (minimum: {FormatBytes(MinimumAvailableBytes)})")
|
||||
.WithEvidence(evidence)
|
||||
.WithCauses(
|
||||
"Memory leak in application",
|
||||
"Insufficient container/VM memory allocation",
|
||||
"Other processes consuming memory")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check current memory usage", "free -h", CommandType.Shell)
|
||||
.AddStep(2, "Identify memory-heavy processes",
|
||||
"ps aux --sort=-%mem | head -20", CommandType.Shell)
|
||||
.AddStep(3, "Increase container memory limit (Docker)",
|
||||
"docker update --memory 4g stellaops-gateway", CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (availableMemory < WarningAvailableBytes)
|
||||
{
|
||||
return Task.FromResult(context.CreateResult(CheckId)
|
||||
.Warn($"Low memory: {FormatBytes(availableMemory)} available (recommended: >{FormatBytes(WarningAvailableBytes)})")
|
||||
.WithEvidence(evidence)
|
||||
.WithCauses("High memory usage", "Growing heap size")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Monitor memory usage", "watch -n 5 free -h", CommandType.Shell))
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(context.CreateResult(CheckId)
|
||||
.Pass($"Memory OK: {FormatBytes(availableMemory)} available")
|
||||
.WithEvidence(evidence)
|
||||
.Build());
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
string[] suffixes = ["B", "KB", "MB", "GB", "TB"];
|
||||
var i = 0;
|
||||
var value = (double)bytes;
|
||||
while (value >= 1024 && i < suffixes.Length - 1)
|
||||
{
|
||||
value /= 1024;
|
||||
i++;
|
||||
}
|
||||
return $"{value:F1} {suffixes[i]}";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: check.runtime.disk.space and check.runtime.disk.permissions
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Verify disk space and write permissions on required directories.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: check.time.sync
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Verify system clock is synchronized.
|
||||
|
||||
```csharp
|
||||
public sealed class TimeSyncCheck : IDoctorCheck
|
||||
{
|
||||
public string CheckId => "check.time.sync";
|
||||
public string Name => "Time Synchronization";
|
||||
public string Description => "Verify system clock is synchronized (NTP)";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
public IReadOnlyList<string> Tags => ["quick", "runtime"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(2);
|
||||
|
||||
private const int MaxClockDriftSeconds = 5;
|
||||
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
// Check against well-known NTP or HTTP time source
|
||||
var systemTime = context.TimeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
// Simple HTTP Date header check (fallback)
|
||||
using var httpClient = context.Services.GetService<IHttpClientFactory>()
|
||||
?.CreateClient("TimeCheck");
|
||||
|
||||
if (httpClient is null)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Skip("HTTP client not available for time check")
|
||||
.Build();
|
||||
}
|
||||
|
||||
var response = await httpClient.SendAsync(
|
||||
new HttpRequestMessage(HttpMethod.Head, "https://www.google.com"), ct);
|
||||
|
||||
if (response.Headers.Date.HasValue)
|
||||
{
|
||||
var serverTime = response.Headers.Date.Value.UtcDateTime;
|
||||
var drift = Math.Abs((systemTime.UtcDateTime - serverTime).TotalSeconds);
|
||||
|
||||
var evidence = context.CreateEvidence()
|
||||
.Add("SystemTime", systemTime.ToString("O", CultureInfo.InvariantCulture))
|
||||
.Add("ServerTime", serverTime.ToString("O", CultureInfo.InvariantCulture))
|
||||
.Add("DriftSeconds", drift.ToString("F2", CultureInfo.InvariantCulture))
|
||||
.Add("MaxAllowedDrift", MaxClockDriftSeconds.ToString(CultureInfo.InvariantCulture))
|
||||
.Build("Time synchronization status");
|
||||
|
||||
if (drift > MaxClockDriftSeconds)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Warn($"Clock drift detected: {drift:F1}s (max allowed: {MaxClockDriftSeconds}s)")
|
||||
.WithEvidence(evidence)
|
||||
.WithCauses(
|
||||
"NTP synchronization not enabled",
|
||||
"NTP daemon not running",
|
||||
"Network blocking NTP traffic")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check NTP status", "timedatectl status", CommandType.Shell)
|
||||
.AddStep(2, "Enable NTP synchronization", "sudo timedatectl set-ntp true", CommandType.Shell)
|
||||
.AddStep(3, "Force immediate sync", "sudo systemctl restart systemd-timesyncd", CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Pass($"Clock synchronized (drift: {drift:F2}s)")
|
||||
.WithEvidence(evidence)
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Warn($"Could not verify time sync: {ex.Message}")
|
||||
.WithEvidence(eb => eb.Add("Error", ex.Message))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Skip("Could not determine time sync status")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: check.crypto.profiles
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Verify crypto profile is valid and providers are available.
|
||||
|
||||
**Migrate from:** `src/Cli/StellaOps.Cli/Services/CryptoProfileValidator.cs`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Test Suite
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```
|
||||
src/Doctor/__Tests/StellaOps.Doctor.Plugin.Core.Tests/
|
||||
├── CoreDoctorPluginTests.cs
|
||||
├── Checks/
|
||||
│ ├── RequiredConfigCheckTests.cs
|
||||
│ ├── ConfigSyntaxCheckTests.cs
|
||||
│ ├── DotNetRuntimeCheckTests.cs
|
||||
│ ├── MemoryCheckTests.cs
|
||||
│ ├── DiskSpaceCheckTests.cs
|
||||
│ ├── DiskPermissionsCheckTests.cs
|
||||
│ ├── TimeSyncCheckTests.cs
|
||||
│ └── CryptoProfilesCheckTests.cs
|
||||
└── Fixtures/
|
||||
└── TestConfiguration.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Sprint)
|
||||
|
||||
- [ ] All 9 checks implemented
|
||||
- [ ] All checks produce evidence
|
||||
- [ ] All checks produce remediation commands
|
||||
- [ ] Plugin registered via DI
|
||||
- [ ] Unit test coverage >= 85%
|
||||
- [ ] No compiler warnings
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date | Entry |
|
||||
|------|-------|
|
||||
| 12-Jan-2026 | Sprint created |
|
||||
| | |
|
||||
@@ -1,509 +0,0 @@
|
||||
# SPRINT: Doctor Database Plugin - Connectivity and Migrations
|
||||
|
||||
> **Implementation ID:** 20260112
|
||||
> **Sprint ID:** 001_003
|
||||
> **Module:** LB (Library)
|
||||
> **Status:** DONE
|
||||
> **Created:** 12-Jan-2026
|
||||
> **Depends On:** 001_001
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implement the Database plugin providing 8 diagnostic checks for PostgreSQL connectivity, migration state, schema integrity, and connection pool health.
|
||||
|
||||
---
|
||||
|
||||
## Working Directory
|
||||
|
||||
```
|
||||
src/Doctor/__Plugins/StellaOps.Doctor.Plugin.Database/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Check Catalog
|
||||
|
||||
| CheckId | Name | Severity | Tags | Description |
|
||||
|---------|------|----------|------|-------------|
|
||||
| `check.database.connectivity` | DB Connectivity | Fail | quick, database | PostgreSQL connection successful |
|
||||
| `check.database.version` | DB Version | Warn | database | PostgreSQL version meets requirements (>=16) |
|
||||
| `check.database.migrations.pending` | Pending Migrations | Fail | database, migrations | No pending release migrations exist |
|
||||
| `check.database.migrations.checksum` | Migration Checksums | Fail | database, migrations, security | Applied migration checksums match source |
|
||||
| `check.database.migrations.lock` | Migration Locks | Warn | database, migrations | No stale migration locks exist |
|
||||
| `check.database.schema.{schema}` | Schema Exists | Fail | database | Schema exists with expected tables |
|
||||
| `check.database.connections.pool` | Connection Pool | Warn | database, performance | Connection pool healthy, not exhausted |
|
||||
| `check.database.replication.lag` | Replication Lag | Warn | database | Replication lag within threshold |
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
### Task 1: Plugin Structure
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```
|
||||
StellaOps.Doctor.Plugin.Database/
|
||||
├── DatabaseDoctorPlugin.cs
|
||||
├── Checks/
|
||||
│ ├── ConnectivityCheck.cs
|
||||
│ ├── VersionCheck.cs
|
||||
│ ├── PendingMigrationsCheck.cs
|
||||
│ ├── MigrationChecksumCheck.cs
|
||||
│ ├── MigrationLockCheck.cs
|
||||
│ ├── SchemaExistsCheck.cs
|
||||
│ ├── ConnectionPoolCheck.cs
|
||||
│ └── ReplicationLagCheck.cs
|
||||
├── Services/
|
||||
│ ├── DatabaseHealthService.cs
|
||||
│ └── MigrationStatusReader.cs
|
||||
└── StellaOps.Doctor.Plugin.Database.csproj
|
||||
```
|
||||
|
||||
**DatabaseDoctorPlugin:**
|
||||
|
||||
```csharp
|
||||
public sealed class DatabaseDoctorPlugin : IDoctorPlugin
|
||||
{
|
||||
public string PluginId => "stellaops.doctor.database";
|
||||
public string DisplayName => "Database";
|
||||
public DoctorCategory Category => DoctorCategory.Database;
|
||||
public Version Version => new(1, 0, 0);
|
||||
public Version MinEngineVersion => new(1, 0, 0);
|
||||
|
||||
public bool IsAvailable(IServiceProvider services)
|
||||
{
|
||||
// Available if connection string is configured
|
||||
var config = services.GetService<IConfiguration>();
|
||||
return !string.IsNullOrEmpty(config?["ConnectionStrings:StellaOps"]);
|
||||
}
|
||||
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
|
||||
{
|
||||
var checks = new List<IDoctorCheck>
|
||||
{
|
||||
new ConnectivityCheck(),
|
||||
new VersionCheck(),
|
||||
new PendingMigrationsCheck(),
|
||||
new MigrationChecksumCheck(),
|
||||
new MigrationLockCheck(),
|
||||
new ConnectionPoolCheck()
|
||||
};
|
||||
|
||||
// Add schema checks for each configured module
|
||||
var modules = GetConfiguredModules(context);
|
||||
foreach (var module in modules)
|
||||
{
|
||||
checks.Add(new SchemaExistsCheck(module.SchemaName, module.ExpectedTables));
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
// Pre-warm connection pool
|
||||
var factory = context.Services.GetService<NpgsqlDataSourceFactory>();
|
||||
if (factory is not null)
|
||||
{
|
||||
await using var connection = await factory.OpenConnectionAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: check.database.connectivity
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```csharp
|
||||
public sealed class ConnectivityCheck : IDoctorCheck
|
||||
{
|
||||
public string CheckId => "check.database.connectivity";
|
||||
public string Name => "Database Connectivity";
|
||||
public string Description => "Verify PostgreSQL connection is successful";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
public IReadOnlyList<string> Tags => ["quick", "database"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(2);
|
||||
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var connectionString = context.Configuration["ConnectionStrings:StellaOps"];
|
||||
if (string.IsNullOrEmpty(connectionString))
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail("Database connection string not configured")
|
||||
.WithEvidence(eb => eb.Add("ConfigKey", "ConnectionStrings:StellaOps"))
|
||||
.WithCauses("Connection string not set in configuration")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Set connection string environment variable",
|
||||
"export STELLAOPS_POSTGRES_CONNECTION=\"Host=localhost;Database=stellaops;Username=stella_app;Password={PASSWORD}\"",
|
||||
CommandType.Shell))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var startTime = context.TimeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT version(), current_database(), current_user";
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
var version = reader.GetString(0);
|
||||
var database = reader.GetString(1);
|
||||
var user = reader.GetString(2);
|
||||
var latency = context.TimeProvider.GetUtcNow() - startTime;
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Pass($"PostgreSQL connection successful (latency: {latency.TotalMilliseconds:F0}ms)")
|
||||
.WithEvidence(eb => eb
|
||||
.AddConnectionString("Connection", connectionString)
|
||||
.Add("ServerVersion", version)
|
||||
.Add("Database", database)
|
||||
.Add("User", user)
|
||||
.Add("LatencyMs", latency.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture)))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
catch (NpgsqlException ex) when (ex.InnerException is SocketException)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail("Connection refused - PostgreSQL may not be running")
|
||||
.WithEvidence(eb => eb
|
||||
.AddConnectionString("Connection", connectionString)
|
||||
.Add("Error", ex.Message))
|
||||
.WithCauses(
|
||||
"PostgreSQL service not running",
|
||||
"Wrong hostname or port",
|
||||
"Firewall blocking connection")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check PostgreSQL is running", "sudo systemctl status postgresql", CommandType.Shell)
|
||||
.AddStep(2, "Check port binding", "sudo ss -tlnp | grep 5432", CommandType.Shell)
|
||||
.AddStep(3, "Check firewall", "sudo ufw status | grep 5432", CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (NpgsqlException ex) when (ex.SqlState == "28P01")
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail("Authentication failed - check username and password")
|
||||
.WithEvidence(eb => eb
|
||||
.AddConnectionString("Connection", connectionString)
|
||||
.Add("SqlState", ex.SqlState ?? "unknown")
|
||||
.Add("Error", ex.Message))
|
||||
.WithCauses(
|
||||
"Wrong password",
|
||||
"User does not exist",
|
||||
"pg_hba.conf denying connection")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Test connection manually",
|
||||
"psql \"host=localhost dbname=stellaops user=stella_app\" -c \"SELECT 1\"",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Check pg_hba.conf",
|
||||
"sudo cat /etc/postgresql/16/main/pg_hba.conf | grep stellaops",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"Connection failed: {ex.Message}")
|
||||
.WithEvidence(eb => eb
|
||||
.AddConnectionString("Connection", connectionString)
|
||||
.Add("Error", ex.Message)
|
||||
.Add("ExceptionType", ex.GetType().Name))
|
||||
.WithCauses("Unexpected connection error")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail("Connection failed: no data returned")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: check.database.migrations.pending
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Integrate with existing `IMigrationRunner` from `StellaOps.Infrastructure.Postgres`.
|
||||
|
||||
```csharp
|
||||
public sealed class PendingMigrationsCheck : IDoctorCheck
|
||||
{
|
||||
public string CheckId => "check.database.migrations.pending";
|
||||
public string Name => "Pending Migrations";
|
||||
public string Description => "Verify no pending release migrations exist";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
public IReadOnlyList<string> Tags => ["database", "migrations"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var migrationRunner = context.Services.GetService<IMigrationRunner>();
|
||||
if (migrationRunner is null)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Skip("Migration runner not available")
|
||||
.Build();
|
||||
}
|
||||
|
||||
var allPending = new List<PendingMigrationInfo>();
|
||||
|
||||
// Check each module schema
|
||||
var modules = new[] { "auth", "scanner", "orchestrator", "concelier", "policy" };
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
var pending = await GetPendingMigrationsAsync(migrationRunner, module, ct);
|
||||
allPending.AddRange(pending);
|
||||
}
|
||||
|
||||
if (allPending.Count == 0)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Pass("No pending migrations")
|
||||
.WithEvidence(eb => eb.Add("CheckedSchemas", string.Join(", ", modules)))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var bySchema = allPending.GroupBy(p => p.Schema).ToList();
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"{allPending.Count} pending migration(s) detected across {bySchema.Count} schema(s)")
|
||||
.WithEvidence(eb =>
|
||||
{
|
||||
foreach (var group in bySchema)
|
||||
{
|
||||
eb.Add($"Schema.{group.Key}", string.Join(", ", group.Select(p => p.Name)));
|
||||
}
|
||||
eb.Add("TotalPending", allPending.Count);
|
||||
})
|
||||
.WithCauses(
|
||||
"Release migrations not applied before deployment",
|
||||
"Migration files added after last deployment",
|
||||
"Schema out of sync with application version")
|
||||
.WithRemediation(rb => rb
|
||||
.WithSafetyNote("Always backup database before running migrations")
|
||||
.RequiresBackup()
|
||||
.AddStep(1, "Backup database first (RECOMMENDED)",
|
||||
"pg_dump -h localhost -U stella_admin -d stellaops -F c -f stellaops_backup_$(date +%Y%m%d_%H%M%S).dump",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Check migration status for all modules",
|
||||
"stella system migrations-status",
|
||||
CommandType.Shell)
|
||||
.AddStep(3, "Apply pending release migrations",
|
||||
"stella system migrations-run --category release",
|
||||
CommandType.Shell)
|
||||
.AddStep(4, "Verify all migrations applied",
|
||||
"stella system migrations-status --verify",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PendingMigrationInfo(string Schema, string Name, string Category);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: check.database.migrations.checksum
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Verify applied migration checksums match source files.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: check.database.migrations.lock
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Check for stale advisory locks.
|
||||
|
||||
```csharp
|
||||
public sealed class MigrationLockCheck : IDoctorCheck
|
||||
{
|
||||
public string CheckId => "check.database.migrations.lock";
|
||||
public string Name => "Migration Locks";
|
||||
public string Description => "Verify no stale migration locks exist";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
public IReadOnlyList<string> Tags => ["database", "migrations"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(2);
|
||||
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var connectionString = context.Configuration["ConnectionStrings:StellaOps"];
|
||||
|
||||
try
|
||||
{
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString!);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
|
||||
// Check for advisory locks on migration lock keys
|
||||
cmd.CommandText = @"
|
||||
SELECT l.pid, l.granted, a.state, a.query,
|
||||
NOW() - a.query_start AS duration
|
||||
FROM pg_locks l
|
||||
JOIN pg_stat_activity a ON l.pid = a.pid
|
||||
WHERE l.locktype = 'advisory'
|
||||
AND l.objid IN (SELECT hashtext(schema_name || '_migrations')
|
||||
FROM information_schema.schemata
|
||||
WHERE schema_name LIKE 'stella%')";
|
||||
|
||||
var locks = new List<MigrationLock>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
locks.Add(new MigrationLock(
|
||||
reader.GetInt32(0),
|
||||
reader.GetBoolean(1),
|
||||
reader.GetString(2),
|
||||
reader.GetString(3),
|
||||
reader.GetTimeSpan(4)));
|
||||
}
|
||||
|
||||
if (locks.Count == 0)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Pass("No migration locks held")
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Check if any locks are stale (held > 5 minutes with idle connection)
|
||||
var staleLocks = locks.Where(l => l.Duration > TimeSpan.FromMinutes(5) && l.State == "idle").ToList();
|
||||
|
||||
if (staleLocks.Count > 0)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Warn($"{staleLocks.Count} stale migration lock(s) detected")
|
||||
.WithEvidence(eb =>
|
||||
{
|
||||
foreach (var l in staleLocks)
|
||||
{
|
||||
eb.Add($"Lock.PID{l.Pid}", $"State: {l.State}, Duration: {l.Duration}");
|
||||
}
|
||||
})
|
||||
.WithCauses(
|
||||
"Migration process crashed while holding lock",
|
||||
"Connection not properly closed after migration")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check for active locks",
|
||||
"psql -d stellaops -c \"SELECT * FROM pg_locks WHERE locktype = 'advisory';\"",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Identify lock holder process",
|
||||
"psql -d stellaops -c \"SELECT pid, query, state FROM pg_stat_activity WHERE pid IN (SELECT pid FROM pg_locks WHERE locktype = 'advisory');\"",
|
||||
CommandType.Shell)
|
||||
.AddStep(3, "Clear stale lock (if process is dead)",
|
||||
"# WARNING: Only if you are certain no migration is running\npsql -d stellaops -c \"SELECT pg_advisory_unlock_all();\"",
|
||||
CommandType.SQL))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Pass($"{locks.Count} active migration lock(s) - migrations in progress")
|
||||
.WithEvidence(eb =>
|
||||
{
|
||||
foreach (var l in locks)
|
||||
{
|
||||
eb.Add($"Lock.PID{l.Pid}", $"State: {l.State}, Duration: {l.Duration}");
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"Could not check migration locks: {ex.Message}")
|
||||
.WithEvidence(eb => eb.Add("Error", ex.Message))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record MigrationLock(int Pid, bool Granted, string State, string Query, TimeSpan Duration);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: check.database.connections.pool
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Check connection pool health.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: check.database.schema.{schema}
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Dynamic check for each configured schema.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Test Suite
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```
|
||||
src/Doctor/__Tests/StellaOps.Doctor.Plugin.Database.Tests/
|
||||
├── DatabaseDoctorPluginTests.cs
|
||||
├── Checks/
|
||||
│ ├── ConnectivityCheckTests.cs
|
||||
│ ├── PendingMigrationsCheckTests.cs
|
||||
│ └── MigrationLockCheckTests.cs
|
||||
└── Fixtures/
|
||||
└── PostgresTestFixture.cs # Uses Testcontainers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Package/Module | Status |
|
||||
|------------|----------------|--------|
|
||||
| Npgsql | Npgsql | EXISTS |
|
||||
| IMigrationRunner | StellaOps.Infrastructure.Postgres | EXISTS |
|
||||
| Testcontainers.PostgreSql | Testing | EXISTS |
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Sprint)
|
||||
|
||||
- [ ] All 8 checks implemented
|
||||
- [ ] Integration with existing migration framework
|
||||
- [ ] Connection string redaction in evidence
|
||||
- [ ] Unit tests with Testcontainers
|
||||
- [ ] Test coverage >= 85%
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date | Entry |
|
||||
|------|-------|
|
||||
| 12-Jan-2026 | Sprint created |
|
||||
| | |
|
||||
@@ -1,661 +0,0 @@
|
||||
# SPRINT: Doctor Service Graph and Security Plugins
|
||||
|
||||
> **Implementation ID:** 20260112
|
||||
> **Sprint ID:** 001_004
|
||||
> **Module:** LB (Library)
|
||||
> **Status:** DONE
|
||||
> **Created:** 12-Jan-2026
|
||||
> **Depends On:** 001_001
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implement Service Graph and Security plugins providing 15 diagnostic checks for inter-service communication, authentication providers, TLS certificates, and secrets management.
|
||||
|
||||
---
|
||||
|
||||
## Working Directory
|
||||
|
||||
```
|
||||
src/Doctor/__Plugins/
|
||||
├── StellaOps.Doctor.Plugin.ServiceGraph/
|
||||
└── StellaOps.Doctor.Plugin.Security/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Check Catalog
|
||||
|
||||
### Service Graph Plugin (6 checks)
|
||||
|
||||
| CheckId | Name | Severity | Tags | Description |
|
||||
|---------|------|----------|------|-------------|
|
||||
| `check.services.gateway.running` | Gateway Running | Fail | quick, services | Gateway service running and accepting connections |
|
||||
| `check.services.gateway.routing` | Gateway Routing | Fail | services, routing | Gateway can route to backend services |
|
||||
| `check.services.{service}.health` | Service Health | Fail | services | Service health endpoint returns healthy |
|
||||
| `check.services.{service}.connectivity` | Service Connectivity | Warn | services | Service reachable from gateway |
|
||||
| `check.services.authority.connectivity` | Authority Connectivity | Fail | services, auth | Authority service reachable |
|
||||
| `check.services.router.transport` | Router Transport | Warn | services | Router transport healthy |
|
||||
|
||||
### Security Plugin (9 checks)
|
||||
|
||||
| CheckId | Name | Severity | Tags | Description |
|
||||
|---------|------|----------|------|-------------|
|
||||
| `check.auth.oidc.discovery` | OIDC Discovery | Fail | auth, oidc | OIDC discovery endpoint accessible |
|
||||
| `check.auth.oidc.jwks` | OIDC JWKS | Fail | auth, oidc | JWKS endpoint returns valid keys |
|
||||
| `check.auth.ldap.bind` | LDAP Bind | Fail | auth, ldap | LDAP bind succeeds with service account |
|
||||
| `check.auth.ldap.search` | LDAP Search | Warn | auth, ldap | LDAP search base accessible |
|
||||
| `check.auth.ldap.groups` | LDAP Groups | Warn | auth, ldap | Group mapping functional |
|
||||
| `check.tls.certificates.expiry` | TLS Expiry | Warn | security, tls | TLS certificates not expiring soon |
|
||||
| `check.tls.certificates.chain` | TLS Chain | Fail | security, tls | TLS certificate chain valid |
|
||||
| `check.secrets.vault.connectivity` | Vault Connectivity | Fail | security, vault | Vault server reachable |
|
||||
| `check.secrets.vault.auth` | Vault Auth | Fail | security, vault | Vault authentication successful |
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
### Task 1: Service Graph Plugin Structure
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```
|
||||
StellaOps.Doctor.Plugin.ServiceGraph/
|
||||
├── ServiceGraphDoctorPlugin.cs
|
||||
├── Checks/
|
||||
│ ├── GatewayRunningCheck.cs
|
||||
│ ├── GatewayRoutingCheck.cs
|
||||
│ ├── ServiceHealthCheck.cs
|
||||
│ ├── ServiceConnectivityCheck.cs
|
||||
│ ├── AuthorityConnectivityCheck.cs
|
||||
│ └── RouterTransportCheck.cs
|
||||
├── Services/
|
||||
│ └── ServiceGraphHealthReader.cs
|
||||
└── StellaOps.Doctor.Plugin.ServiceGraph.csproj
|
||||
```
|
||||
|
||||
**ServiceGraphDoctorPlugin:**
|
||||
|
||||
```csharp
|
||||
public sealed class ServiceGraphDoctorPlugin : IDoctorPlugin
|
||||
{
|
||||
public string PluginId => "stellaops.doctor.servicegraph";
|
||||
public string DisplayName => "Service Graph";
|
||||
public DoctorCategory Category => DoctorCategory.ServiceGraph;
|
||||
public Version Version => new(1, 0, 0);
|
||||
public Version MinEngineVersion => new(1, 0, 0);
|
||||
|
||||
private static readonly string[] CoreServices =
|
||||
[
|
||||
"gateway", "authority", "scanner", "orchestrator",
|
||||
"concelier", "policy", "scheduler", "notifier"
|
||||
];
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
|
||||
{
|
||||
var checks = new List<IDoctorCheck>
|
||||
{
|
||||
new GatewayRunningCheck(),
|
||||
new GatewayRoutingCheck(),
|
||||
new AuthorityConnectivityCheck(),
|
||||
new RouterTransportCheck()
|
||||
};
|
||||
|
||||
// Add health checks for each configured service
|
||||
foreach (var service in CoreServices)
|
||||
{
|
||||
checks.Add(new ServiceHealthCheck(service));
|
||||
checks.Add(new ServiceConnectivityCheck(service));
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: check.services.gateway.running
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```csharp
|
||||
public sealed class GatewayRunningCheck : IDoctorCheck
|
||||
{
|
||||
public string CheckId => "check.services.gateway.running";
|
||||
public string Name => "Gateway Running";
|
||||
public string Description => "Verify Gateway service is running and accepting connections";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
public IReadOnlyList<string> Tags => ["quick", "services"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(2);
|
||||
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var gatewayUrl = context.Configuration["Gateway:Url"] ?? "http://localhost:8080";
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = context.Services.GetRequiredService<IHttpClientFactory>()
|
||||
.CreateClient("DoctorHealthCheck");
|
||||
|
||||
var response = await httpClient.GetAsync($"{gatewayUrl}/health/live", ct);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Pass("Gateway is running and accepting connections")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("GatewayUrl", gatewayUrl)
|
||||
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ResponseTime", response.Headers.Date?.ToString("O", CultureInfo.InvariantCulture) ?? "unknown"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"Gateway returned {(int)response.StatusCode} {response.ReasonPhrase}")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("GatewayUrl", gatewayUrl)
|
||||
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"Gateway service unhealthy",
|
||||
"Gateway dependencies failing")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check gateway logs", "sudo journalctl -u stellaops-gateway -n 100", CommandType.Shell)
|
||||
.AddStep(2, "Restart gateway", "sudo systemctl restart stellaops-gateway", CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"Cannot connect to Gateway: {ex.Message}")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("GatewayUrl", gatewayUrl)
|
||||
.Add("Error", ex.Message))
|
||||
.WithCauses(
|
||||
"Gateway service not running",
|
||||
"Wrong gateway URL configured",
|
||||
"Firewall blocking connection")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check service status", "sudo systemctl status stellaops-gateway", CommandType.Shell)
|
||||
.AddStep(2, "Check port binding", "sudo ss -tlnp | grep 8080", CommandType.Shell)
|
||||
.AddStep(3, "Start gateway", "sudo systemctl start stellaops-gateway", CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: check.services.{service}.health
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Dynamic check for each service.
|
||||
|
||||
```csharp
|
||||
public sealed class ServiceHealthCheck : IDoctorCheck
|
||||
{
|
||||
private readonly string _serviceName;
|
||||
|
||||
public ServiceHealthCheck(string serviceName)
|
||||
{
|
||||
_serviceName = serviceName;
|
||||
}
|
||||
|
||||
public string CheckId => $"check.services.{_serviceName}.health";
|
||||
public string Name => $"{Capitalize(_serviceName)} Health";
|
||||
public string Description => $"Verify {_serviceName} service health endpoint returns healthy";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
public IReadOnlyList<string> Tags => ["services"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
|
||||
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Skip if service is not configured
|
||||
var serviceUrl = context.Configuration[$"Services:{Capitalize(_serviceName)}:Url"];
|
||||
return !string.IsNullOrEmpty(serviceUrl);
|
||||
}
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var serviceUrl = context.Configuration[$"Services:{Capitalize(_serviceName)}:Url"];
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = context.Services.GetRequiredService<IHttpClientFactory>()
|
||||
.CreateClient("DoctorHealthCheck");
|
||||
|
||||
var startTime = context.TimeProvider.GetUtcNow();
|
||||
var response = await httpClient.GetAsync($"{serviceUrl}/healthz", ct);
|
||||
var latency = context.TimeProvider.GetUtcNow() - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
return context.CreateResult(CheckId)
|
||||
.Pass($"{Capitalize(_serviceName)} is healthy (latency: {latency.TotalMilliseconds:F0}ms)")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("ServiceUrl", serviceUrl)
|
||||
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
|
||||
.Add("LatencyMs", latency.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture))
|
||||
.Add("Response", content.Length > 500 ? content[..500] + "..." : content))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"{Capitalize(_serviceName)} is unhealthy: {response.StatusCode}")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("ServiceUrl", serviceUrl)
|
||||
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"Service dependencies failing",
|
||||
"Database connection lost",
|
||||
"Out of memory")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check service logs",
|
||||
$"sudo journalctl -u stellaops-{_serviceName} -n 100", CommandType.Shell)
|
||||
.AddStep(2, "Check detailed health",
|
||||
$"curl -s {serviceUrl}/health/details | jq", CommandType.Shell)
|
||||
.AddStep(3, "Restart service",
|
||||
$"sudo systemctl restart stellaops-{_serviceName}", CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"Cannot reach {_serviceName}: {ex.Message}")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("ServiceUrl", serviceUrl)
|
||||
.Add("Error", ex.Message))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Capitalize(string s) =>
|
||||
string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0], CultureInfo.InvariantCulture) + s[1..];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Security Plugin Structure
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```
|
||||
StellaOps.Doctor.Plugin.Security/
|
||||
├── SecurityDoctorPlugin.cs
|
||||
├── Checks/
|
||||
│ ├── OidcDiscoveryCheck.cs
|
||||
│ ├── OidcJwksCheck.cs
|
||||
│ ├── LdapBindCheck.cs
|
||||
│ ├── LdapSearchCheck.cs
|
||||
│ ├── LdapGroupsCheck.cs
|
||||
│ ├── TlsExpiryCheck.cs
|
||||
│ ├── TlsChainCheck.cs
|
||||
│ ├── VaultConnectivityCheck.cs
|
||||
│ └── VaultAuthCheck.cs
|
||||
└── StellaOps.Doctor.Plugin.Security.csproj
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: check.auth.oidc.discovery
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```csharp
|
||||
public sealed class OidcDiscoveryCheck : IDoctorCheck
|
||||
{
|
||||
public string CheckId => "check.auth.oidc.discovery";
|
||||
public string Name => "OIDC Discovery";
|
||||
public string Description => "Verify OIDC discovery endpoint is accessible";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
public IReadOnlyList<string> Tags => ["auth", "oidc"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
|
||||
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var issuer = context.Configuration["Authority:Issuer"];
|
||||
return !string.IsNullOrEmpty(issuer);
|
||||
}
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var issuer = context.Configuration["Authority:Issuer"]!;
|
||||
var discoveryUrl = issuer.TrimEnd('/') + "/.well-known/openid-configuration";
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = context.Services.GetRequiredService<IHttpClientFactory>()
|
||||
.CreateClient("DoctorHealthCheck");
|
||||
|
||||
var response = await httpClient.GetAsync(discoveryUrl, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"OIDC discovery endpoint returned {response.StatusCode}")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("DiscoveryUrl", discoveryUrl)
|
||||
.Add("Issuer", issuer)
|
||||
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"Authority service not running",
|
||||
"Wrong issuer URL configured",
|
||||
"TLS certificate issue")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Test discovery endpoint manually",
|
||||
$"curl -v {discoveryUrl}", CommandType.Shell)
|
||||
.AddStep(2, "Check Authority service",
|
||||
"sudo systemctl status stellaops-authority", CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
var doc = JsonDocument.Parse(content);
|
||||
|
||||
// Validate required fields
|
||||
var requiredFields = new[] { "issuer", "authorization_endpoint", "token_endpoint", "jwks_uri" };
|
||||
var missingFields = requiredFields
|
||||
.Where(f => !doc.RootElement.TryGetProperty(f, out _))
|
||||
.ToList();
|
||||
|
||||
if (missingFields.Count > 0)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Warn($"OIDC discovery missing fields: {string.Join(", ", missingFields)}")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("DiscoveryUrl", discoveryUrl)
|
||||
.Add("MissingFields", string.Join(", ", missingFields)))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Pass("OIDC discovery endpoint accessible and valid")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("DiscoveryUrl", discoveryUrl)
|
||||
.Add("Issuer", doc.RootElement.GetProperty("issuer").GetString()!)
|
||||
.Add("JwksUri", doc.RootElement.GetProperty("jwks_uri").GetString()!))
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"Cannot reach OIDC discovery: {ex.Message}")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("DiscoveryUrl", discoveryUrl)
|
||||
.Add("Error", ex.Message))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: check.auth.ldap.bind
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Integrate with existing Authority LDAP plugin.
|
||||
|
||||
```csharp
|
||||
public sealed class LdapBindCheck : IDoctorCheck
|
||||
{
|
||||
public string CheckId => "check.auth.ldap.bind";
|
||||
public string Name => "LDAP Bind";
|
||||
public string Description => "Verify LDAP bind succeeds with service account";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
public IReadOnlyList<string> Tags => ["auth", "ldap"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var ldapHost = context.Configuration["Authority:Plugins:Ldap:Connection:Host"];
|
||||
return !string.IsNullOrEmpty(ldapHost);
|
||||
}
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var config = context.Configuration.GetSection("Authority:Plugins:Ldap");
|
||||
var host = config["Connection:Host"]!;
|
||||
var port = config.GetValue("Connection:Port", 636);
|
||||
var bindDn = config["Connection:BindDn"]!;
|
||||
var useTls = config.GetValue("Security:RequireTls", true);
|
||||
|
||||
try
|
||||
{
|
||||
// Use existing Authority LDAP plugin if available
|
||||
var ldapPlugin = context.Services.GetService<IIdentityProviderPlugin>();
|
||||
if (ldapPlugin is not null)
|
||||
{
|
||||
var healthResult = await ldapPlugin.CheckHealthAsync(ct);
|
||||
|
||||
if (healthResult.Status == AuthorityPluginHealthStatus.Healthy)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Pass("LDAP bind successful")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("Host", host)
|
||||
.Add("Port", port)
|
||||
.Add("BindDn", bindDn)
|
||||
.Add("UseTls", useTls))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"LDAP bind failed: {healthResult.Message}")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("Host", host)
|
||||
.Add("Port", port)
|
||||
.Add("BindDn", bindDn)
|
||||
.Add("Error", healthResult.Message ?? "Unknown error"))
|
||||
.WithCauses(
|
||||
"Invalid bind credentials",
|
||||
"LDAP server unreachable",
|
||||
"TLS certificate issue",
|
||||
"Firewall blocking LDAPS port")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Test LDAP connection",
|
||||
$"ldapsearch -H ldaps://{host}:{port} -D \"{bindDn}\" -W -b \"\" -s base",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Check TLS certificate",
|
||||
$"openssl s_client -connect {host}:{port} -showcerts",
|
||||
CommandType.Shell)
|
||||
.AddStep(3, "Verify credentials",
|
||||
"# Check bind password in secrets store", CommandType.Manual))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Skip("LDAP plugin not available")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"LDAP check failed: {ex.Message}")
|
||||
.WithEvidence(eb => eb.Add("Error", ex.Message))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: check.tls.certificates.expiry
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Check TLS certificate expiration.
|
||||
|
||||
```csharp
|
||||
public sealed class TlsExpiryCheck : IDoctorCheck
|
||||
{
|
||||
public string CheckId => "check.tls.certificates.expiry";
|
||||
public string Name => "TLS Certificate Expiry";
|
||||
public string Description => "Verify TLS certificates are not expiring soon";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
public IReadOnlyList<string> Tags => ["security", "tls"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(2);
|
||||
|
||||
private const int WarningDays = 30;
|
||||
private const int CriticalDays = 7;
|
||||
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var certPaths = GetConfiguredCertPaths(context);
|
||||
var now = context.TimeProvider.GetUtcNow();
|
||||
var issues = new List<CertificateIssue>();
|
||||
var healthy = new List<CertificateInfo>();
|
||||
|
||||
foreach (var path in certPaths)
|
||||
{
|
||||
if (!File.Exists(path)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var cert = X509Certificate2.CreateFromPemFile(path);
|
||||
var daysRemaining = (cert.NotAfter - now.UtcDateTime).TotalDays;
|
||||
|
||||
var info = new CertificateInfo(
|
||||
path,
|
||||
cert.Subject,
|
||||
cert.NotAfter,
|
||||
(int)daysRemaining);
|
||||
|
||||
if (daysRemaining < CriticalDays)
|
||||
{
|
||||
issues.Add(new CertificateIssue(info, "critical"));
|
||||
}
|
||||
else if (daysRemaining < WarningDays)
|
||||
{
|
||||
issues.Add(new CertificateIssue(info, "warning"));
|
||||
}
|
||||
else
|
||||
{
|
||||
healthy.Add(info);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new CertificateIssue(
|
||||
new CertificateInfo(path, "unknown", DateTime.MinValue, 0),
|
||||
$"error: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.Count == 0)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Pass($"All {healthy.Count} certificates valid (nearest expiry: {healthy.Min(c => c.DaysRemaining)} days)")
|
||||
.WithEvidence(eb =>
|
||||
{
|
||||
foreach (var cert in healthy)
|
||||
{
|
||||
eb.Add($"Cert.{Path.GetFileName(cert.Path)}",
|
||||
$"Expires: {cert.NotAfter:yyyy-MM-dd} ({cert.DaysRemaining} days)");
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
var critical = issues.Where(i => i.Level == "critical").ToList();
|
||||
var severity = critical.Count > 0 ? DoctorSeverity.Fail : DoctorSeverity.Warn;
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.WithSeverity(severity)
|
||||
.WithDiagnosis($"{issues.Count} certificate(s) expiring soon or invalid")
|
||||
.WithEvidence(eb =>
|
||||
{
|
||||
foreach (var issue in issues.OrderBy(i => i.Cert.DaysRemaining))
|
||||
{
|
||||
eb.Add($"Issue.{Path.GetFileName(issue.Cert.Path)}",
|
||||
$"{issue.Level}: {issue.Cert.Subject}, expires {issue.Cert.NotAfter:yyyy-MM-dd} ({issue.Cert.DaysRemaining} days)");
|
||||
}
|
||||
})
|
||||
.WithCauses(
|
||||
"Certificate renewal not scheduled",
|
||||
"ACME/Let's Encrypt automation not configured",
|
||||
"Manual renewal overdue")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check certificate details",
|
||||
$"openssl x509 -in {{CERT_PATH}} -noout -dates -subject",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Renew certificate (certbot)",
|
||||
"sudo certbot renew --cert-name stellaops.example.com",
|
||||
CommandType.Shell)
|
||||
.AddStep(3, "Restart services",
|
||||
"sudo systemctl restart stellaops-gateway",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetConfiguredCertPaths(DoctorPluginContext context)
|
||||
{
|
||||
// Common certificate locations
|
||||
yield return "/etc/ssl/certs/stellaops.crt";
|
||||
yield return "/etc/stellaops/tls/tls.crt";
|
||||
|
||||
// From configuration
|
||||
var configPath = context.Configuration["Tls:CertificatePath"];
|
||||
if (!string.IsNullOrEmpty(configPath))
|
||||
yield return configPath;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record CertificateInfo(string Path, string Subject, DateTime NotAfter, int DaysRemaining);
|
||||
internal sealed record CertificateIssue(CertificateInfo Cert, string Level);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: check.secrets.vault.connectivity
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Check Vault connectivity.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Test Suite
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Sprint)
|
||||
|
||||
- [ ] Service Graph plugin with 6 checks
|
||||
- [ ] Security plugin with 9 checks
|
||||
- [ ] Integration with existing Authority plugins
|
||||
- [ ] TLS certificate checking
|
||||
- [ ] Test coverage >= 85%
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date | Entry |
|
||||
|------|-------|
|
||||
| 12-Jan-2026 | Sprint created |
|
||||
| | |
|
||||
@@ -1,518 +0,0 @@
|
||||
# SPRINT: Doctor Integration Plugins - SCM and Registry
|
||||
|
||||
> **Implementation ID:** 20260112
|
||||
> **Sprint ID:** 001_005
|
||||
> **Module:** LB (Library)
|
||||
> **Status:** DONE
|
||||
> **Created:** 12-Jan-2026
|
||||
> **Depends On:** 001_001
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implement Integration plugins for SCM (GitHub, GitLab) and Container Registry (Harbor, ECR) providers. These plugins leverage the existing integration connector infrastructure from ReleaseOrchestrator.
|
||||
|
||||
---
|
||||
|
||||
## Working Directory
|
||||
|
||||
```
|
||||
src/Doctor/__Plugins/
|
||||
├── StellaOps.Doctor.Plugin.Scm/
|
||||
└── StellaOps.Doctor.Plugin.Registry/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Check Catalog
|
||||
|
||||
### SCM Plugin (8 checks)
|
||||
|
||||
| CheckId | Name | Severity | Tags | Description |
|
||||
|---------|------|----------|------|-------------|
|
||||
| `check.integration.scm.github.connectivity` | GitHub Connectivity | Fail | integration, scm | GitHub API reachable |
|
||||
| `check.integration.scm.github.auth` | GitHub Auth | Fail | integration, scm | GitHub authentication valid |
|
||||
| `check.integration.scm.github.permissions` | GitHub Permissions | Warn | integration, scm | Required permissions granted |
|
||||
| `check.integration.scm.github.ratelimit` | GitHub Rate Limit | Warn | integration, scm | Rate limit not exhausted |
|
||||
| `check.integration.scm.gitlab.connectivity` | GitLab Connectivity | Fail | integration, scm | GitLab API reachable |
|
||||
| `check.integration.scm.gitlab.auth` | GitLab Auth | Fail | integration, scm | GitLab authentication valid |
|
||||
| `check.integration.scm.gitlab.permissions` | GitLab Permissions | Warn | integration, scm | Required permissions granted |
|
||||
| `check.integration.scm.gitlab.ratelimit` | GitLab Rate Limit | Warn | integration, scm | Rate limit not exhausted |
|
||||
|
||||
### Registry Plugin (6 checks)
|
||||
|
||||
| CheckId | Name | Severity | Tags | Description |
|
||||
|---------|------|----------|------|-------------|
|
||||
| `check.integration.registry.harbor.connectivity` | Harbor Connectivity | Fail | integration, registry | Harbor API reachable |
|
||||
| `check.integration.registry.harbor.auth` | Harbor Auth | Fail | integration, registry | Harbor authentication valid |
|
||||
| `check.integration.registry.harbor.pull` | Harbor Pull | Warn | integration, registry | Can pull from configured projects |
|
||||
| `check.integration.registry.ecr.connectivity` | ECR Connectivity | Fail | integration, registry | ECR reachable |
|
||||
| `check.integration.registry.ecr.auth` | ECR Auth | Fail | integration, registry | ECR authentication valid |
|
||||
| `check.integration.registry.ecr.pull` | ECR Pull | Warn | integration, registry | Can pull from configured repos |
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
### Task 1: Integration with Existing Infrastructure
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Leverage existing interfaces from ReleaseOrchestrator:
|
||||
|
||||
```csharp
|
||||
// From src/ReleaseOrchestrator/__Libraries/.../IntegrationHub/
|
||||
public interface IIntegrationConnectorCapability
|
||||
{
|
||||
Task<ConnectionTestResult> TestConnectionAsync(ConnectorContext context, CancellationToken ct);
|
||||
Task<ConfigValidationResult> ValidateConfigAsync(JsonElement config, CancellationToken ct);
|
||||
IReadOnlyList<string> GetSupportedOperations();
|
||||
}
|
||||
|
||||
// Existing doctor checks from IntegrationHub
|
||||
public interface IDoctorCheck // Existing
|
||||
{
|
||||
string Name { get; }
|
||||
string Category { get; }
|
||||
Task<CheckResult> ExecuteAsync(...);
|
||||
}
|
||||
```
|
||||
|
||||
**Strategy:** Create adapter plugins that wrap existing `IIntegrationConnectorCapability` implementations.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: SCM Plugin Structure
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```
|
||||
StellaOps.Doctor.Plugin.Scm/
|
||||
├── ScmDoctorPlugin.cs
|
||||
├── Checks/
|
||||
│ ├── BaseScmCheck.cs
|
||||
│ ├── ScmConnectivityCheck.cs
|
||||
│ ├── ScmAuthCheck.cs
|
||||
│ ├── ScmPermissionsCheck.cs
|
||||
│ └── ScmRateLimitCheck.cs
|
||||
├── Providers/
|
||||
│ ├── GitHubCheckProvider.cs
|
||||
│ └── GitLabCheckProvider.cs
|
||||
└── StellaOps.Doctor.Plugin.Scm.csproj
|
||||
```
|
||||
|
||||
**ScmDoctorPlugin:**
|
||||
|
||||
```csharp
|
||||
public sealed class ScmDoctorPlugin : IDoctorPlugin
|
||||
{
|
||||
public string PluginId => "stellaops.doctor.scm";
|
||||
public string DisplayName => "SCM Integrations";
|
||||
public DoctorCategory Category => DoctorCategory.Integration;
|
||||
public Version Version => new(1, 0, 0);
|
||||
public Version MinEngineVersion => new(1, 0, 0);
|
||||
|
||||
public bool IsAvailable(IServiceProvider services)
|
||||
{
|
||||
// Available if any SCM integration is configured
|
||||
var integrationManager = services.GetService<IIntegrationManager>();
|
||||
return integrationManager is not null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
|
||||
{
|
||||
var checks = new List<IDoctorCheck>();
|
||||
var integrationManager = context.Services.GetService<IIntegrationManager>();
|
||||
|
||||
if (integrationManager is null) return checks;
|
||||
|
||||
// Get all enabled SCM integrations
|
||||
var scmIntegrations = integrationManager
|
||||
.ListByTypeAsync(IntegrationType.Scm, CancellationToken.None)
|
||||
.GetAwaiter().GetResult()
|
||||
.Where(i => i.Enabled)
|
||||
.ToList();
|
||||
|
||||
foreach (var integration in scmIntegrations)
|
||||
{
|
||||
var provider = integration.Provider.ToString().ToLowerInvariant();
|
||||
checks.Add(new ScmConnectivityCheck(integration, provider));
|
||||
checks.Add(new ScmAuthCheck(integration, provider));
|
||||
checks.Add(new ScmPermissionsCheck(integration, provider));
|
||||
checks.Add(new ScmRateLimitCheck(integration, provider));
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: check.integration.scm.github.connectivity
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```csharp
|
||||
public sealed class ScmConnectivityCheck : IDoctorCheck
|
||||
{
|
||||
private readonly Integration _integration;
|
||||
private readonly string _provider;
|
||||
|
||||
public ScmConnectivityCheck(Integration integration, string provider)
|
||||
{
|
||||
_integration = integration;
|
||||
_provider = provider;
|
||||
}
|
||||
|
||||
public string CheckId => $"check.integration.scm.{_provider}.connectivity";
|
||||
public string Name => $"{Capitalize(_provider)} Connectivity";
|
||||
public string Description => $"Verify {Capitalize(_provider)} API is reachable";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
public IReadOnlyList<string> Tags => ["integration", "scm"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var connectorFactory = context.Services.GetRequiredService<IConnectorFactory>();
|
||||
var connector = await connectorFactory.CreateAsync(_integration, ct);
|
||||
|
||||
try
|
||||
{
|
||||
var testResult = await connector.TestConnectionAsync(
|
||||
new ConnectorContext { TimeProvider = context.TimeProvider },
|
||||
ct);
|
||||
|
||||
if (testResult.Success)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Pass($"{Capitalize(_provider)} API is reachable (latency: {testResult.LatencyMs}ms)")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("Integration", _integration.Name)
|
||||
.Add("Provider", _provider)
|
||||
.Add("BaseUrl", _integration.Config.GetProperty("baseUrl").GetString() ?? "default")
|
||||
.Add("LatencyMs", testResult.LatencyMs.ToString(CultureInfo.InvariantCulture)))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"{Capitalize(_provider)} connection failed: {testResult.ErrorMessage}")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("Integration", _integration.Name)
|
||||
.Add("Provider", _provider)
|
||||
.Add("Error", testResult.ErrorMessage ?? "Unknown error"))
|
||||
.WithCauses(
|
||||
$"{Capitalize(_provider)} API is down",
|
||||
"Network connectivity issue",
|
||||
"DNS resolution failure",
|
||||
"Proxy configuration issue")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Test API connectivity",
|
||||
GetConnectivityCommand(_provider),
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Check DNS resolution",
|
||||
$"nslookup {GetApiHost(_provider)}",
|
||||
CommandType.Shell)
|
||||
.AddStep(3, "Check firewall/proxy",
|
||||
"curl -v --proxy $HTTP_PROXY " + GetApiHost(_provider),
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"Connection test failed: {ex.Message}")
|
||||
.WithEvidence(eb => eb.Add("Error", ex.Message))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetConnectivityCommand(string provider) => provider switch
|
||||
{
|
||||
"github" => "curl -s -o /dev/null -w '%{http_code}' https://api.github.com/zen",
|
||||
"gitlab" => "curl -s -o /dev/null -w '%{http_code}' https://gitlab.com/api/v4/version",
|
||||
_ => $"curl -s https://{provider}.com"
|
||||
};
|
||||
|
||||
private static string GetApiHost(string provider) => provider switch
|
||||
{
|
||||
"github" => "api.github.com",
|
||||
"gitlab" => "gitlab.com",
|
||||
_ => $"{provider}.com"
|
||||
};
|
||||
|
||||
private static string Capitalize(string s) =>
|
||||
string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0], CultureInfo.InvariantCulture) + s[1..];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: check.integration.scm.github.ratelimit
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```csharp
|
||||
public sealed class ScmRateLimitCheck : IDoctorCheck
|
||||
{
|
||||
private readonly Integration _integration;
|
||||
private readonly string _provider;
|
||||
|
||||
public ScmRateLimitCheck(Integration integration, string provider)
|
||||
{
|
||||
_integration = integration;
|
||||
_provider = provider;
|
||||
}
|
||||
|
||||
public string CheckId => $"check.integration.scm.{_provider}.ratelimit";
|
||||
public string Name => $"{Capitalize(_provider)} Rate Limit";
|
||||
public string Description => $"Verify {Capitalize(_provider)} rate limit not exhausted";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
public IReadOnlyList<string> Tags => ["integration", "scm"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
|
||||
|
||||
private const int WarningThreshold = 100; // Warn when < 100 remaining
|
||||
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var connectorFactory = context.Services.GetRequiredService<IConnectorFactory>();
|
||||
var connector = await connectorFactory.CreateAsync(_integration, ct);
|
||||
|
||||
if (connector is not IRateLimitInfo rateLimitConnector)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Skip($"{Capitalize(_provider)} connector does not support rate limit info")
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var rateLimitInfo = await rateLimitConnector.GetRateLimitInfoAsync(ct);
|
||||
|
||||
var evidence = context.CreateEvidence()
|
||||
.Add("Integration", _integration.Name)
|
||||
.Add("Limit", rateLimitInfo.Limit.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("Remaining", rateLimitInfo.Remaining.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("ResetsAt", rateLimitInfo.ResetsAt.ToString("O", CultureInfo.InvariantCulture))
|
||||
.Add("UsedPercent", $"{(rateLimitInfo.Limit - rateLimitInfo.Remaining) * 100.0 / rateLimitInfo.Limit:F1}%")
|
||||
.Build("Rate limit status");
|
||||
|
||||
if (rateLimitInfo.Remaining == 0)
|
||||
{
|
||||
var resetsIn = rateLimitInfo.ResetsAt - context.TimeProvider.GetUtcNow();
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"Rate limit exhausted - resets in {resetsIn.TotalMinutes:F0} minutes")
|
||||
.WithEvidence(evidence)
|
||||
.WithCauses(
|
||||
"Too many API requests",
|
||||
"CI/CD jobs consuming quota",
|
||||
"Webhook flood")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Wait for rate limit reset",
|
||||
$"# Rate limit resets at {rateLimitInfo.ResetsAt:HH:mm:ss} UTC",
|
||||
CommandType.Manual)
|
||||
.AddStep(2, "Check for excessive API usage",
|
||||
"stella integrations usage --integration " + _integration.Name,
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (rateLimitInfo.Remaining < WarningThreshold)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Warn($"Rate limit low: {rateLimitInfo.Remaining}/{rateLimitInfo.Limit} remaining")
|
||||
.WithEvidence(evidence)
|
||||
.WithCauses("High API usage rate")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Pass($"Rate limit OK: {rateLimitInfo.Remaining}/{rateLimitInfo.Limit} remaining")
|
||||
.WithEvidence(evidence)
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Warn($"Could not check rate limit: {ex.Message}")
|
||||
.WithEvidence(eb => eb.Add("Error", ex.Message))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Capitalize(string s) =>
|
||||
string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0], CultureInfo.InvariantCulture) + s[1..];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Registry Plugin Structure
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```
|
||||
StellaOps.Doctor.Plugin.Registry/
|
||||
├── RegistryDoctorPlugin.cs
|
||||
├── Checks/
|
||||
│ ├── RegistryConnectivityCheck.cs
|
||||
│ ├── RegistryAuthCheck.cs
|
||||
│ └── RegistryPullCheck.cs
|
||||
├── Providers/
|
||||
│ ├── HarborCheckProvider.cs
|
||||
│ └── EcrCheckProvider.cs
|
||||
└── StellaOps.Doctor.Plugin.Registry.csproj
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: check.integration.registry.harbor.connectivity
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
---
|
||||
|
||||
### Task 7: check.integration.registry.harbor.pull
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```csharp
|
||||
public sealed class RegistryPullCheck : IDoctorCheck
|
||||
{
|
||||
private readonly Integration _integration;
|
||||
private readonly string _provider;
|
||||
|
||||
public RegistryPullCheck(Integration integration, string provider)
|
||||
{
|
||||
_integration = integration;
|
||||
_provider = provider;
|
||||
}
|
||||
|
||||
public string CheckId => $"check.integration.registry.{_provider}.pull";
|
||||
public string Name => $"{Capitalize(_provider)} Pull Access";
|
||||
public string Description => $"Verify can pull images from {Capitalize(_provider)}";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
public IReadOnlyList<string> Tags => ["integration", "registry"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
|
||||
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var connectorFactory = context.Services.GetRequiredService<IConnectorFactory>();
|
||||
var connector = await connectorFactory.CreateAsync(_integration, ct);
|
||||
|
||||
if (connector is not IRegistryConnectorCapability registryConnector)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Skip("Integration is not a registry connector")
|
||||
.Build();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get test repository from config or use library
|
||||
var testRepo = _integration.Config.TryGetProperty("testRepository", out var tr)
|
||||
? tr.GetString()
|
||||
: "library/alpine";
|
||||
|
||||
var canPull = await registryConnector.CanPullAsync(testRepo!, ct);
|
||||
|
||||
if (canPull)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Pass($"Pull access verified for {testRepo}")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("Integration", _integration.Name)
|
||||
.Add("TestRepository", testRepo!))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Warn($"Cannot pull from {testRepo}")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("Integration", _integration.Name)
|
||||
.Add("TestRepository", testRepo!))
|
||||
.WithCauses(
|
||||
"Insufficient permissions",
|
||||
"Repository does not exist",
|
||||
"Private repository without access")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Test pull manually",
|
||||
$"docker pull {_integration.Config.GetProperty("host").GetString()}/{testRepo}",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Check repository permissions",
|
||||
"# Verify user has pull access in registry UI", CommandType.Manual))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Fail($"Pull check failed: {ex.Message}")
|
||||
.WithEvidence(eb => eb.Add("Error", ex.Message))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Capitalize(string s) =>
|
||||
string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0], CultureInfo.InvariantCulture) + s[1..];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Test Suite
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```
|
||||
src/Doctor/__Tests/
|
||||
├── StellaOps.Doctor.Plugin.Scm.Tests/
|
||||
│ └── Checks/
|
||||
│ ├── ScmConnectivityCheckTests.cs
|
||||
│ └── ScmRateLimitCheckTests.cs
|
||||
└── StellaOps.Doctor.Plugin.Registry.Tests/
|
||||
└── Checks/
|
||||
└── RegistryPullCheckTests.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Package/Module | Status |
|
||||
|------------|----------------|--------|
|
||||
| IIntegrationManager | ReleaseOrchestrator.IntegrationHub | EXISTS |
|
||||
| IConnectorFactory | ReleaseOrchestrator.IntegrationHub | EXISTS |
|
||||
| IRateLimitInfo | ReleaseOrchestrator.IntegrationHub | EXISTS |
|
||||
| IRegistryConnectorCapability | ReleaseOrchestrator.Plugin | EXISTS |
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Sprint)
|
||||
|
||||
- [ ] SCM plugin with 8 checks (GitHub, GitLab)
|
||||
- [ ] Registry plugin with 6 checks (Harbor, ECR)
|
||||
- [ ] Integration with existing connector infrastructure
|
||||
- [ ] Dynamic check generation based on configured integrations
|
||||
- [ ] Test coverage >= 85%
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date | Entry |
|
||||
|------|-------|
|
||||
| 12-Jan-2026 | Sprint created |
|
||||
| | |
|
||||
@@ -1,591 +0,0 @@
|
||||
# SPRINT: CLI Doctor Command Implementation
|
||||
|
||||
> **Implementation ID:** 20260112
|
||||
> **Sprint ID:** 001_006
|
||||
> **Module:** CLI
|
||||
> **Status:** DONE
|
||||
> **Created:** 12-Jan-2026
|
||||
> **Depends On:** 001_002 (Core Plugin)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implement the `stella doctor` CLI command that provides comprehensive self-service diagnostics from the terminal. This is the primary interface for operators to diagnose and fix issues.
|
||||
|
||||
---
|
||||
|
||||
## Working Directory
|
||||
|
||||
```
|
||||
src/Cli/StellaOps.Cli/Commands/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Command Specification
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
stella doctor [options]
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Short | Type | Default | Description |
|
||||
|--------|-------|------|---------|-------------|
|
||||
| `--format` | `-f` | enum | `text` | Output format: `text`, `json`, `markdown` |
|
||||
| `--quick` | `-q` | flag | false | Run only quick checks (tagged `quick`) |
|
||||
| `--full` | | flag | false | Run all checks including slow/intensive |
|
||||
| `--category` | `-c` | string[] | all | Filter by category |
|
||||
| `--plugin` | `-p` | string[] | all | Filter by plugin ID |
|
||||
| `--check` | | string | | Run single check by ID |
|
||||
| `--severity` | `-s` | enum[] | all | Filter output by severity |
|
||||
| `--export` | `-e` | path | | Export report to file |
|
||||
| `--timeout` | `-t` | duration | 30s | Per-check timeout |
|
||||
| `--parallel` | | int | 4 | Max parallel check execution |
|
||||
| `--no-remediation` | | flag | false | Skip remediation output |
|
||||
| `--verbose` | `-v` | flag | false | Include detailed evidence |
|
||||
| `--tenant` | | string | | Tenant context |
|
||||
| `--list-checks` | | flag | false | List available checks |
|
||||
| `--list-plugins` | | flag | false | List available plugins |
|
||||
|
||||
### Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | All checks passed |
|
||||
| 1 | One or more warnings |
|
||||
| 2 | One or more failures |
|
||||
| 3 | Doctor engine error |
|
||||
| 4 | Invalid arguments |
|
||||
| 5 | Timeout exceeded |
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
### Task 1: Command Group Structure
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```
|
||||
src/Cli/StellaOps.Cli/
|
||||
├── Commands/
|
||||
│ └── DoctorCommandGroup.cs
|
||||
├── Handlers/
|
||||
│ └── DoctorCommandHandlers.cs
|
||||
└── Output/
|
||||
└── DoctorOutputRenderer.cs
|
||||
```
|
||||
|
||||
**DoctorCommandGroup:**
|
||||
|
||||
```csharp
|
||||
public sealed class DoctorCommandGroup : ICommandGroup
|
||||
{
|
||||
public Command Create()
|
||||
{
|
||||
var command = new Command("doctor", "Run diagnostic checks on the Stella Ops deployment");
|
||||
|
||||
// Format option
|
||||
var formatOption = new Option<OutputFormat>(
|
||||
aliases: ["--format", "-f"],
|
||||
description: "Output format: text, json, markdown",
|
||||
getDefaultValue: () => OutputFormat.Text);
|
||||
command.AddOption(formatOption);
|
||||
|
||||
// Mode options
|
||||
var quickOption = new Option<bool>(
|
||||
"--quick",
|
||||
"Run only quick checks");
|
||||
quickOption.AddAlias("-q");
|
||||
command.AddOption(quickOption);
|
||||
|
||||
var fullOption = new Option<bool>(
|
||||
"--full",
|
||||
"Run all checks including slow/intensive");
|
||||
command.AddOption(fullOption);
|
||||
|
||||
// Filter options
|
||||
var categoryOption = new Option<string[]>(
|
||||
aliases: ["--category", "-c"],
|
||||
description: "Filter by category (core, database, servicegraph, integration, security, observability)");
|
||||
command.AddOption(categoryOption);
|
||||
|
||||
var pluginOption = new Option<string[]>(
|
||||
aliases: ["--plugin", "-p"],
|
||||
description: "Filter by plugin ID");
|
||||
command.AddOption(pluginOption);
|
||||
|
||||
var checkOption = new Option<string>(
|
||||
"--check",
|
||||
"Run single check by ID");
|
||||
command.AddOption(checkOption);
|
||||
|
||||
var severityOption = new Option<DoctorSeverity[]>(
|
||||
aliases: ["--severity", "-s"],
|
||||
description: "Filter output by severity (pass, info, warn, fail)");
|
||||
command.AddOption(severityOption);
|
||||
|
||||
// Output options
|
||||
var exportOption = new Option<FileInfo?>(
|
||||
aliases: ["--export", "-e"],
|
||||
description: "Export report to file");
|
||||
command.AddOption(exportOption);
|
||||
|
||||
var verboseOption = new Option<bool>(
|
||||
aliases: ["--verbose", "-v"],
|
||||
description: "Include detailed evidence in output");
|
||||
command.AddOption(verboseOption);
|
||||
|
||||
var noRemediationOption = new Option<bool>(
|
||||
"--no-remediation",
|
||||
"Skip remediation command generation");
|
||||
command.AddOption(noRemediationOption);
|
||||
|
||||
// Execution options
|
||||
var timeoutOption = new Option<TimeSpan>(
|
||||
aliases: ["--timeout", "-t"],
|
||||
description: "Per-check timeout",
|
||||
getDefaultValue: () => TimeSpan.FromSeconds(30));
|
||||
command.AddOption(timeoutOption);
|
||||
|
||||
var parallelOption = new Option<int>(
|
||||
"--parallel",
|
||||
getDefaultValue: () => 4,
|
||||
description: "Max parallel check execution");
|
||||
command.AddOption(parallelOption);
|
||||
|
||||
var tenantOption = new Option<string?>(
|
||||
"--tenant",
|
||||
"Tenant context for multi-tenant checks");
|
||||
command.AddOption(tenantOption);
|
||||
|
||||
// List options
|
||||
var listChecksOption = new Option<bool>(
|
||||
"--list-checks",
|
||||
"List available checks and exit");
|
||||
command.AddOption(listChecksOption);
|
||||
|
||||
var listPluginsOption = new Option<bool>(
|
||||
"--list-plugins",
|
||||
"List available plugins and exit");
|
||||
command.AddOption(listPluginsOption);
|
||||
|
||||
command.SetHandler(DoctorCommandHandlers.RunAsync);
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Command Handler
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```csharp
|
||||
public static class DoctorCommandHandlers
|
||||
{
|
||||
public static async Task<int> RunAsync(InvocationContext context)
|
||||
{
|
||||
var ct = context.GetCancellationToken();
|
||||
var services = context.GetRequiredService<IServiceProvider>();
|
||||
var console = context.Console;
|
||||
|
||||
// Parse options
|
||||
var format = context.ParseResult.GetValueForOption<OutputFormat>("--format");
|
||||
var quick = context.ParseResult.GetValueForOption<bool>("--quick");
|
||||
var full = context.ParseResult.GetValueForOption<bool>("--full");
|
||||
var categories = context.ParseResult.GetValueForOption<string[]>("--category");
|
||||
var plugins = context.ParseResult.GetValueForOption<string[]>("--plugin");
|
||||
var checkId = context.ParseResult.GetValueForOption<string>("--check");
|
||||
var severities = context.ParseResult.GetValueForOption<DoctorSeverity[]>("--severity");
|
||||
var exportPath = context.ParseResult.GetValueForOption<FileInfo?>("--export");
|
||||
var verbose = context.ParseResult.GetValueForOption<bool>("--verbose");
|
||||
var noRemediation = context.ParseResult.GetValueForOption<bool>("--no-remediation");
|
||||
var timeout = context.ParseResult.GetValueForOption<TimeSpan>("--timeout");
|
||||
var parallel = context.ParseResult.GetValueForOption<int>("--parallel");
|
||||
var tenant = context.ParseResult.GetValueForOption<string?>("--tenant");
|
||||
var listChecks = context.ParseResult.GetValueForOption<bool>("--list-checks");
|
||||
var listPlugins = context.ParseResult.GetValueForOption<bool>("--list-plugins");
|
||||
|
||||
var engine = services.GetRequiredService<DoctorEngine>();
|
||||
var renderer = services.GetRequiredService<DoctorOutputRenderer>();
|
||||
|
||||
// Handle list operations
|
||||
if (listPlugins)
|
||||
{
|
||||
var pluginList = engine.ListPlugins();
|
||||
renderer.RenderPluginList(console, pluginList, format);
|
||||
return CliExitCodes.Success;
|
||||
}
|
||||
|
||||
if (listChecks)
|
||||
{
|
||||
var checkList = engine.ListChecks(new DoctorRunOptions
|
||||
{
|
||||
Categories = categories?.ToImmutableArray(),
|
||||
Plugins = plugins?.ToImmutableArray()
|
||||
});
|
||||
renderer.RenderCheckList(console, checkList, format);
|
||||
return CliExitCodes.Success;
|
||||
}
|
||||
|
||||
// Build run options
|
||||
var runMode = quick ? DoctorRunMode.Quick :
|
||||
full ? DoctorRunMode.Full :
|
||||
DoctorRunMode.Normal;
|
||||
|
||||
var options = new DoctorRunOptions
|
||||
{
|
||||
Mode = runMode,
|
||||
Categories = categories?.ToImmutableArray(),
|
||||
Plugins = plugins?.ToImmutableArray(),
|
||||
CheckIds = string.IsNullOrEmpty(checkId) ? null : [checkId],
|
||||
Timeout = timeout,
|
||||
Parallelism = parallel,
|
||||
IncludeRemediation = !noRemediation,
|
||||
TenantId = tenant
|
||||
};
|
||||
|
||||
// Run doctor with progress
|
||||
var progress = new Progress<DoctorCheckProgress>(p =>
|
||||
{
|
||||
if (format == OutputFormat.Text)
|
||||
{
|
||||
renderer.RenderProgress(console, p);
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var report = await engine.RunAsync(options, progress, ct);
|
||||
|
||||
// Filter by severity if requested
|
||||
var filteredReport = severities?.Length > 0
|
||||
? FilterReportBySeverity(report, severities)
|
||||
: report;
|
||||
|
||||
// Render output
|
||||
var formatOptions = new ReportFormatOptions
|
||||
{
|
||||
Verbose = verbose,
|
||||
IncludeRemediation = !noRemediation,
|
||||
SeverityFilter = severities?.ToImmutableArray()
|
||||
};
|
||||
|
||||
renderer.RenderReport(console, filteredReport, format, formatOptions);
|
||||
|
||||
// Export if requested
|
||||
if (exportPath is not null)
|
||||
{
|
||||
await ExportReportAsync(filteredReport, exportPath, format, formatOptions, ct);
|
||||
console.WriteLine($"Report exported to: {exportPath.FullName}");
|
||||
}
|
||||
|
||||
// Return appropriate exit code
|
||||
return report.OverallSeverity switch
|
||||
{
|
||||
DoctorSeverity.Pass => CliExitCodes.Success,
|
||||
DoctorSeverity.Info => CliExitCodes.Success,
|
||||
DoctorSeverity.Warn => CliExitCodes.DoctorWarnings,
|
||||
DoctorSeverity.Fail => CliExitCodes.DoctorFailures,
|
||||
_ => CliExitCodes.Success
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
console.Error.WriteLine("Doctor run cancelled");
|
||||
return CliExitCodes.DoctorTimeout;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
console.Error.WriteLine($"Doctor engine error: {ex.Message}");
|
||||
return CliExitCodes.DoctorEngineError;
|
||||
}
|
||||
}
|
||||
|
||||
private static DoctorReport FilterReportBySeverity(
|
||||
DoctorReport report,
|
||||
DoctorSeverity[] severities)
|
||||
{
|
||||
var severitySet = severities.ToHashSet();
|
||||
return report with
|
||||
{
|
||||
Results = report.Results
|
||||
.Where(r => severitySet.Contains(r.Severity))
|
||||
.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task ExportReportAsync(
|
||||
DoctorReport report,
|
||||
FileInfo exportPath,
|
||||
OutputFormat format,
|
||||
ReportFormatOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var formatter = format switch
|
||||
{
|
||||
OutputFormat.Json => new JsonReportFormatter(),
|
||||
OutputFormat.Markdown => new MarkdownReportFormatter(),
|
||||
_ => new TextReportFormatter()
|
||||
};
|
||||
|
||||
var content = formatter.FormatReport(report, options);
|
||||
await File.WriteAllTextAsync(exportPath.FullName, content, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public enum OutputFormat
|
||||
{
|
||||
Text,
|
||||
Json,
|
||||
Markdown
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Output Renderer
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
```csharp
|
||||
public sealed class DoctorOutputRenderer
|
||||
{
|
||||
private readonly IAnsiConsole _console;
|
||||
|
||||
public DoctorOutputRenderer(IAnsiConsole console)
|
||||
{
|
||||
_console = console;
|
||||
}
|
||||
|
||||
public void RenderProgress(IConsole console, DoctorCheckProgress progress)
|
||||
{
|
||||
// Clear previous line and show progress
|
||||
console.Write($"\r[{progress.Completed}/{progress.Total}] {progress.CheckId}...".PadRight(80));
|
||||
}
|
||||
|
||||
public void RenderReport(
|
||||
IConsole console,
|
||||
DoctorReport report,
|
||||
OutputFormat format,
|
||||
ReportFormatOptions options)
|
||||
{
|
||||
var formatter = GetFormatter(format);
|
||||
var output = formatter.FormatReport(report, options);
|
||||
console.WriteLine(output);
|
||||
}
|
||||
|
||||
public void RenderPluginList(
|
||||
IConsole console,
|
||||
IReadOnlyList<DoctorPluginMetadata> plugins,
|
||||
OutputFormat format)
|
||||
{
|
||||
if (format == OutputFormat.Json)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(plugins, JsonSerializerOptions.Default);
|
||||
console.WriteLine(json);
|
||||
return;
|
||||
}
|
||||
|
||||
console.WriteLine("Available Doctor Plugins");
|
||||
console.WriteLine("========================");
|
||||
console.WriteLine();
|
||||
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
console.WriteLine($" {plugin.PluginId}");
|
||||
console.WriteLine($" Name: {plugin.DisplayName}");
|
||||
console.WriteLine($" Category: {plugin.Category}");
|
||||
console.WriteLine($" Version: {plugin.Version}");
|
||||
console.WriteLine($" Checks: {plugin.CheckCount}");
|
||||
console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
public void RenderCheckList(
|
||||
IConsole console,
|
||||
IReadOnlyList<DoctorCheckMetadata> checks,
|
||||
OutputFormat format)
|
||||
{
|
||||
if (format == OutputFormat.Json)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(checks, JsonSerializerOptions.Default);
|
||||
console.WriteLine(json);
|
||||
return;
|
||||
}
|
||||
|
||||
console.WriteLine($"Available Checks ({checks.Count})");
|
||||
console.WriteLine("=".PadRight(50, '='));
|
||||
console.WriteLine();
|
||||
|
||||
var byCategory = checks.GroupBy(c => c.Category);
|
||||
|
||||
foreach (var group in byCategory.OrderBy(g => g.Key))
|
||||
{
|
||||
console.WriteLine($"[{group.Key}]");
|
||||
foreach (var check in group.OrderBy(c => c.CheckId))
|
||||
{
|
||||
var tags = string.Join(", ", check.Tags);
|
||||
console.WriteLine($" {check.CheckId}");
|
||||
console.WriteLine($" {check.Description}");
|
||||
console.WriteLine($" Tags: {tags}");
|
||||
console.WriteLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IReportFormatter GetFormatter(OutputFormat format) => format switch
|
||||
{
|
||||
OutputFormat.Json => new JsonReportFormatter(),
|
||||
OutputFormat.Markdown => new MarkdownReportFormatter(),
|
||||
_ => new TextReportFormatter()
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Exit Codes Registration
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Add to `CliExitCodes.cs`:
|
||||
|
||||
```csharp
|
||||
public static class CliExitCodes
|
||||
{
|
||||
// Existing codes...
|
||||
|
||||
// Doctor exit codes (10-19)
|
||||
public const int DoctorWarnings = 10;
|
||||
public const int DoctorFailures = 11;
|
||||
public const int DoctorEngineError = 12;
|
||||
public const int DoctorTimeout = 13;
|
||||
public const int DoctorInvalidArgs = 14;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: DI Registration
|
||||
|
||||
**Status:** TODO
|
||||
|
||||
Register in CLI startup:
|
||||
|
||||
```csharp
|
||||
// In Program.cs or CliBootstrapper.cs
|
||||
services.AddDoctor();
|
||||
services.AddDoctorPlugin<CoreDoctorPlugin>();
|
||||
services.AddDoctorPlugin<DatabaseDoctorPlugin>();
|
||||
services.AddDoctorPlugin<ServiceGraphDoctorPlugin>();
|
||||
services.AddDoctorPlugin<SecurityDoctorPlugin>();
|
||||
services.AddDoctorPlugin<ScmDoctorPlugin>();
|
||||
services.AddDoctorPlugin<RegistryDoctorPlugin>();
|
||||
services.AddSingleton<DoctorOutputRenderer>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Test Suite
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```
|
||||
src/Cli/__Tests/StellaOps.Cli.Tests/Commands/
|
||||
├── DoctorCommandGroupTests.cs
|
||||
├── DoctorCommandHandlersTests.cs
|
||||
└── DoctorOutputRendererTests.cs
|
||||
```
|
||||
|
||||
**Test Scenarios:**
|
||||
|
||||
1. **Command Parsing**
|
||||
- All options parse correctly
|
||||
- Conflicting options handled (--quick vs --full)
|
||||
- Invalid values rejected
|
||||
|
||||
2. **Execution**
|
||||
- Quick mode runs only quick-tagged checks
|
||||
- Full mode runs all checks
|
||||
- Single check by ID works
|
||||
- Category filtering works
|
||||
|
||||
3. **Output**
|
||||
- Text format is human-readable
|
||||
- JSON format is valid JSON
|
||||
- Markdown format is valid markdown
|
||||
- Export creates file with correct content
|
||||
|
||||
4. **Exit Codes**
|
||||
- Returns 0 for all pass
|
||||
- Returns 1 for warnings
|
||||
- Returns 2 for failures
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick health check (default)
|
||||
stella doctor
|
||||
|
||||
# Full diagnostic
|
||||
stella doctor --full
|
||||
|
||||
# Check only database
|
||||
stella doctor --category database
|
||||
|
||||
# Check specific integration
|
||||
stella doctor --plugin scm.github
|
||||
|
||||
# Run single check
|
||||
stella doctor --check check.database.migrations.pending
|
||||
|
||||
# JSON output for CI/CD
|
||||
stella doctor --format json --severity fail,warn
|
||||
|
||||
# Export markdown report
|
||||
stella doctor --full --format markdown --export doctor-report.md
|
||||
|
||||
# Verbose with all evidence
|
||||
stella doctor --verbose --full
|
||||
|
||||
# List available checks
|
||||
stella doctor --list-checks
|
||||
|
||||
# List available plugins
|
||||
stella doctor --list-plugins
|
||||
|
||||
# Quick check with 60s timeout
|
||||
stella doctor --quick --timeout 60s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Sprint)
|
||||
|
||||
- [ ] All command options implemented
|
||||
- [ ] Text output matches specification
|
||||
- [ ] JSON output is valid and complete
|
||||
- [ ] Markdown output suitable for tickets
|
||||
- [ ] Exit codes follow specification
|
||||
- [ ] Progress display during execution
|
||||
- [ ] Export to file works
|
||||
- [ ] Test coverage >= 85%
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date | Entry |
|
||||
|------|-------|
|
||||
| 12-Jan-2026 | Sprint created |
|
||||
| 12-Jan-2026 | Task 6 (Test Suite) completed - 33 tests in DoctorCommandGroupTests covering command structure, options, subcommands, exit codes, and handler registration |
|
||||
@@ -1,589 +0,0 @@
|
||||
# SPRINT: Doctor API Endpoints
|
||||
|
||||
> **Implementation ID:** 20260112
|
||||
> **Sprint ID:** 001_007
|
||||
> **Module:** BE (Backend)
|
||||
> **Status:** DONE
|
||||
> **Created:** 12-Jan-2026
|
||||
> **Depends On:** 001_002 (Core Plugin)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implement REST API endpoints for the Doctor system, enabling programmatic access for CI/CD pipelines, monitoring systems, and the web UI.
|
||||
|
||||
---
|
||||
|
||||
## Working Directory
|
||||
|
||||
```
|
||||
src/Doctor/StellaOps.Doctor.WebService/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Specification
|
||||
|
||||
### Base Path
|
||||
|
||||
```
|
||||
/api/v1/doctor
|
||||
```
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/checks` | List available checks |
|
||||
| `GET` | `/plugins` | List available plugins |
|
||||
| `POST` | `/run` | Execute doctor checks |
|
||||
| `GET` | `/run/{runId}` | Get run results |
|
||||
| `GET` | `/run/{runId}/stream` | SSE stream for progress |
|
||||
| `GET` | `/reports` | List historical reports |
|
||||
| `GET` | `/reports/{reportId}` | Get specific report |
|
||||
| `DELETE` | `/reports/{reportId}` | Delete report |
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
### Task 1: Project Structure
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```
|
||||
StellaOps.Doctor.WebService/
|
||||
├── Endpoints/
|
||||
│ ├── DoctorEndpoints.cs
|
||||
│ ├── ChecksEndpoints.cs
|
||||
│ ├── PluginsEndpoints.cs
|
||||
│ ├── RunEndpoints.cs
|
||||
│ └── ReportsEndpoints.cs
|
||||
├── Models/
|
||||
│ ├── RunDoctorRequest.cs
|
||||
│ ├── RunDoctorResponse.cs
|
||||
│ ├── CheckListResponse.cs
|
||||
│ ├── PluginListResponse.cs
|
||||
│ └── ReportListResponse.cs
|
||||
├── Services/
|
||||
│ ├── DoctorRunService.cs
|
||||
│ └── ReportStorageService.cs
|
||||
├── Program.cs
|
||||
└── StellaOps.Doctor.WebService.csproj
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Endpoint Registration
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```csharp
|
||||
public static class DoctorEndpoints
|
||||
{
|
||||
public static void MapDoctorEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/doctor")
|
||||
.WithTags("Doctor")
|
||||
.RequireAuthorization("doctor:run");
|
||||
|
||||
// Checks
|
||||
group.MapGet("/checks", ChecksEndpoints.ListChecks)
|
||||
.WithName("ListDoctorChecks")
|
||||
.WithSummary("List available diagnostic checks");
|
||||
|
||||
// Plugins
|
||||
group.MapGet("/plugins", PluginsEndpoints.ListPlugins)
|
||||
.WithName("ListDoctorPlugins")
|
||||
.WithSummary("List available doctor plugins");
|
||||
|
||||
// Run
|
||||
group.MapPost("/run", RunEndpoints.StartRun)
|
||||
.WithName("StartDoctorRun")
|
||||
.WithSummary("Start a doctor diagnostic run");
|
||||
|
||||
group.MapGet("/run/{runId}", RunEndpoints.GetRunResult)
|
||||
.WithName("GetDoctorRunResult")
|
||||
.WithSummary("Get results of a doctor run");
|
||||
|
||||
group.MapGet("/run/{runId}/stream", RunEndpoints.StreamRunProgress)
|
||||
.WithName("StreamDoctorRunProgress")
|
||||
.WithSummary("Stream real-time progress of a doctor run");
|
||||
|
||||
// Reports
|
||||
group.MapGet("/reports", ReportsEndpoints.ListReports)
|
||||
.WithName("ListDoctorReports")
|
||||
.WithSummary("List historical doctor reports");
|
||||
|
||||
group.MapGet("/reports/{reportId}", ReportsEndpoints.GetReport)
|
||||
.WithName("GetDoctorReport")
|
||||
.WithSummary("Get a specific doctor report");
|
||||
|
||||
group.MapDelete("/reports/{reportId}", ReportsEndpoints.DeleteReport)
|
||||
.WithName("DeleteDoctorReport")
|
||||
.WithSummary("Delete a doctor report")
|
||||
.RequireAuthorization("doctor:admin");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: List Checks Endpoint
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```csharp
|
||||
public static class ChecksEndpoints
|
||||
{
|
||||
public static async Task<IResult> ListChecks(
|
||||
[FromQuery] string? category,
|
||||
[FromQuery] string? tags,
|
||||
[FromQuery] string? plugin,
|
||||
[FromServices] DoctorEngine engine)
|
||||
{
|
||||
var options = new DoctorRunOptions
|
||||
{
|
||||
Categories = string.IsNullOrEmpty(category) ? null : [category],
|
||||
Plugins = string.IsNullOrEmpty(plugin) ? null : [plugin],
|
||||
Tags = string.IsNullOrEmpty(tags) ? null : tags.Split(',').ToImmutableArray()
|
||||
};
|
||||
|
||||
var checks = engine.ListChecks(options);
|
||||
|
||||
var response = new CheckListResponse
|
||||
{
|
||||
Checks = checks.Select(c => new CheckMetadataDto
|
||||
{
|
||||
CheckId = c.CheckId,
|
||||
Name = c.Name,
|
||||
Description = c.Description,
|
||||
PluginId = c.PluginId,
|
||||
Category = c.Category,
|
||||
DefaultSeverity = c.DefaultSeverity.ToString().ToLowerInvariant(),
|
||||
Tags = c.Tags,
|
||||
EstimatedDurationMs = (int)c.EstimatedDuration.TotalMilliseconds
|
||||
}).ToImmutableArray(),
|
||||
Total = checks.Count
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CheckListResponse
|
||||
{
|
||||
public required IReadOnlyList<CheckMetadataDto> Checks { get; init; }
|
||||
public required int Total { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CheckMetadataDto
|
||||
{
|
||||
public required string CheckId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public string? PluginId { get; init; }
|
||||
public string? Category { get; init; }
|
||||
public required string DefaultSeverity { get; init; }
|
||||
public required IReadOnlyList<string> Tags { get; init; }
|
||||
public int EstimatedDurationMs { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Run Endpoint
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```csharp
|
||||
public static class RunEndpoints
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, DoctorRunState> _runs = new();
|
||||
|
||||
public static async Task<IResult> StartRun(
|
||||
[FromBody] RunDoctorRequest request,
|
||||
[FromServices] DoctorEngine engine,
|
||||
[FromServices] DoctorRunService runService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var runId = await runService.StartRunAsync(request, ct);
|
||||
|
||||
return Results.Accepted(
|
||||
$"/api/v1/doctor/run/{runId}",
|
||||
new RunStartedResponse
|
||||
{
|
||||
RunId = runId,
|
||||
Status = "running",
|
||||
StartedAt = DateTimeOffset.UtcNow,
|
||||
ChecksTotal = request.CheckIds?.Count ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
public static async Task<IResult> GetRunResult(
|
||||
string runId,
|
||||
[FromServices] DoctorRunService runService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await runService.GetRunResultAsync(runId, ct);
|
||||
|
||||
if (result is null)
|
||||
return Results.NotFound(new { error = "Run not found", runId });
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
public static async Task StreamRunProgress(
|
||||
string runId,
|
||||
HttpContext context,
|
||||
[FromServices] DoctorRunService runService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
context.Response.ContentType = "text/event-stream";
|
||||
context.Response.Headers.CacheControl = "no-cache";
|
||||
context.Response.Headers.Connection = "keep-alive";
|
||||
|
||||
await foreach (var progress in runService.StreamProgressAsync(runId, ct))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(progress);
|
||||
await context.Response.WriteAsync($"event: {progress.EventType}\n", ct);
|
||||
await context.Response.WriteAsync($"data: {json}\n\n", ct);
|
||||
await context.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RunDoctorRequest
|
||||
{
|
||||
public string Mode { get; init; } = "quick"; // quick, normal, full
|
||||
public IReadOnlyList<string>? Categories { get; init; }
|
||||
public IReadOnlyList<string>? Plugins { get; init; }
|
||||
public IReadOnlyList<string>? CheckIds { get; init; }
|
||||
public int TimeoutMs { get; init; } = 30000;
|
||||
public int Parallelism { get; init; } = 4;
|
||||
public bool IncludeRemediation { get; init; } = true;
|
||||
public string? TenantId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RunStartedResponse
|
||||
{
|
||||
public required string RunId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
public int ChecksTotal { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Run Service
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```csharp
|
||||
public sealed class DoctorRunService
|
||||
{
|
||||
private readonly DoctorEngine _engine;
|
||||
private readonly IReportStorageService _storage;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, DoctorRunState> _activeRuns = new();
|
||||
|
||||
public DoctorRunService(
|
||||
DoctorEngine engine,
|
||||
IReportStorageService storage,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_engine = engine;
|
||||
_storage = storage;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<string> StartRunAsync(RunDoctorRequest request, CancellationToken ct)
|
||||
{
|
||||
var runMode = Enum.Parse<DoctorRunMode>(request.Mode, ignoreCase: true);
|
||||
var options = new DoctorRunOptions
|
||||
{
|
||||
Mode = runMode,
|
||||
Categories = request.Categories?.ToImmutableArray(),
|
||||
Plugins = request.Plugins?.ToImmutableArray(),
|
||||
CheckIds = request.CheckIds?.ToImmutableArray(),
|
||||
Timeout = TimeSpan.FromMilliseconds(request.TimeoutMs),
|
||||
Parallelism = request.Parallelism,
|
||||
IncludeRemediation = request.IncludeRemediation,
|
||||
TenantId = request.TenantId
|
||||
};
|
||||
|
||||
var runId = GenerateRunId();
|
||||
var state = new DoctorRunState
|
||||
{
|
||||
RunId = runId,
|
||||
Status = "running",
|
||||
StartedAt = _timeProvider.GetUtcNow(),
|
||||
Progress = Channel.CreateUnbounded<DoctorProgressEvent>()
|
||||
};
|
||||
|
||||
_activeRuns[runId] = state;
|
||||
|
||||
// Run in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var progress = new Progress<DoctorCheckProgress>(p =>
|
||||
{
|
||||
state.Progress.Writer.TryWrite(new DoctorProgressEvent
|
||||
{
|
||||
EventType = "check-completed",
|
||||
CheckId = p.CheckId,
|
||||
Severity = p.Severity.ToString().ToLowerInvariant(),
|
||||
Completed = p.Completed,
|
||||
Total = p.Total
|
||||
});
|
||||
});
|
||||
|
||||
var report = await _engine.RunAsync(options, progress, ct);
|
||||
|
||||
state.Report = report;
|
||||
state.Status = "completed";
|
||||
state.CompletedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
state.Progress.Writer.TryWrite(new DoctorProgressEvent
|
||||
{
|
||||
EventType = "run-completed",
|
||||
RunId = runId,
|
||||
Summary = new
|
||||
{
|
||||
passed = report.Summary.Passed,
|
||||
warnings = report.Summary.Warnings,
|
||||
failed = report.Summary.Failed
|
||||
}
|
||||
});
|
||||
|
||||
state.Progress.Writer.Complete();
|
||||
|
||||
// Store report
|
||||
await _storage.StoreReportAsync(report, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
state.Status = "failed";
|
||||
state.Error = ex.Message;
|
||||
state.Progress.Writer.TryComplete(ex);
|
||||
}
|
||||
}, ct);
|
||||
|
||||
return runId;
|
||||
}
|
||||
|
||||
public async Task<DoctorRunResultResponse?> GetRunResultAsync(string runId, CancellationToken ct)
|
||||
{
|
||||
if (_activeRuns.TryGetValue(runId, out var state))
|
||||
{
|
||||
if (state.Report is null)
|
||||
{
|
||||
return new DoctorRunResultResponse
|
||||
{
|
||||
RunId = runId,
|
||||
Status = state.Status,
|
||||
StartedAt = state.StartedAt,
|
||||
Error = state.Error
|
||||
};
|
||||
}
|
||||
|
||||
return MapToResponse(state.Report);
|
||||
}
|
||||
|
||||
// Try to load from storage
|
||||
var report = await _storage.GetReportAsync(runId, ct);
|
||||
return report is null ? null : MapToResponse(report);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<DoctorProgressEvent> StreamProgressAsync(
|
||||
string runId,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
if (!_activeRuns.TryGetValue(runId, out var state))
|
||||
yield break;
|
||||
|
||||
await foreach (var progress in state.Progress.Reader.ReadAllAsync(ct))
|
||||
{
|
||||
yield return progress;
|
||||
}
|
||||
}
|
||||
|
||||
private string GenerateRunId()
|
||||
{
|
||||
var timestamp = _timeProvider.GetUtcNow().ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture);
|
||||
var suffix = Guid.NewGuid().ToString("N")[..6];
|
||||
return $"dr_{timestamp}_{suffix}";
|
||||
}
|
||||
|
||||
private static DoctorRunResultResponse MapToResponse(DoctorReport report) => new()
|
||||
{
|
||||
RunId = report.RunId,
|
||||
Status = "completed",
|
||||
StartedAt = report.StartedAt,
|
||||
CompletedAt = report.CompletedAt,
|
||||
DurationMs = (long)report.Duration.TotalMilliseconds,
|
||||
Summary = new DoctorSummaryDto
|
||||
{
|
||||
Passed = report.Summary.Passed,
|
||||
Info = report.Summary.Info,
|
||||
Warnings = report.Summary.Warnings,
|
||||
Failed = report.Summary.Failed,
|
||||
Skipped = report.Summary.Skipped,
|
||||
Total = report.Summary.Total
|
||||
},
|
||||
OverallSeverity = report.OverallSeverity.ToString().ToLowerInvariant(),
|
||||
Results = report.Results.Select(MapCheckResult).ToImmutableArray()
|
||||
};
|
||||
|
||||
private static DoctorCheckResultDto MapCheckResult(DoctorCheckResult result) => new()
|
||||
{
|
||||
CheckId = result.CheckId,
|
||||
PluginId = result.PluginId,
|
||||
Category = result.Category,
|
||||
Severity = result.Severity.ToString().ToLowerInvariant(),
|
||||
Diagnosis = result.Diagnosis,
|
||||
Evidence = new EvidenceDto
|
||||
{
|
||||
Description = result.Evidence.Description,
|
||||
Data = result.Evidence.Data
|
||||
},
|
||||
LikelyCauses = result.LikelyCauses,
|
||||
Remediation = result.Remediation is null ? null : new RemediationDto
|
||||
{
|
||||
RequiresBackup = result.Remediation.RequiresBackup,
|
||||
SafetyNote = result.Remediation.SafetyNote,
|
||||
Steps = result.Remediation.Steps.Select(s => new RemediationStepDto
|
||||
{
|
||||
Order = s.Order,
|
||||
Description = s.Description,
|
||||
Command = s.Command,
|
||||
CommandType = s.CommandType.ToString().ToLowerInvariant()
|
||||
}).ToImmutableArray()
|
||||
},
|
||||
VerificationCommand = result.VerificationCommand,
|
||||
DurationMs = (int)result.Duration.TotalMilliseconds,
|
||||
ExecutedAt = result.ExecutedAt
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class DoctorRunState
|
||||
{
|
||||
public required string RunId { get; init; }
|
||||
public required string Status { get; set; }
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
public DoctorReport? Report { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public required Channel<DoctorProgressEvent> Progress { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DoctorProgressEvent
|
||||
{
|
||||
public required string EventType { get; init; }
|
||||
public string? RunId { get; init; }
|
||||
public string? CheckId { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public int? Completed { get; init; }
|
||||
public int? Total { get; init; }
|
||||
public object? Summary { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Report Storage Service
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```csharp
|
||||
public interface IReportStorageService
|
||||
{
|
||||
Task StoreReportAsync(DoctorReport report, CancellationToken ct);
|
||||
Task<DoctorReport?> GetReportAsync(string runId, CancellationToken ct);
|
||||
Task<IReadOnlyList<DoctorReportSummary>> ListReportsAsync(int limit, int offset, CancellationToken ct);
|
||||
Task DeleteReportAsync(string runId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class PostgresReportStorageService : IReportStorageService
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
|
||||
public PostgresReportStorageService(NpgsqlDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
}
|
||||
|
||||
public async Task StoreReportAsync(DoctorReport report, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
|
||||
cmd.CommandText = @"
|
||||
INSERT INTO doctor.reports (run_id, started_at, completed_at, duration_ms, overall_severity, summary_json, results_json)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (run_id) DO UPDATE SET
|
||||
completed_at = EXCLUDED.completed_at,
|
||||
duration_ms = EXCLUDED.duration_ms,
|
||||
overall_severity = EXCLUDED.overall_severity,
|
||||
summary_json = EXCLUDED.summary_json,
|
||||
results_json = EXCLUDED.results_json";
|
||||
|
||||
cmd.Parameters.AddWithValue(report.RunId);
|
||||
cmd.Parameters.AddWithValue(report.StartedAt);
|
||||
cmd.Parameters.AddWithValue(report.CompletedAt);
|
||||
cmd.Parameters.AddWithValue((long)report.Duration.TotalMilliseconds);
|
||||
cmd.Parameters.AddWithValue(report.OverallSeverity.ToString());
|
||||
cmd.Parameters.AddWithValue(JsonSerializer.Serialize(report.Summary));
|
||||
cmd.Parameters.AddWithValue(JsonSerializer.Serialize(report.Results));
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
// Additional methods...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Test Suite
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```
|
||||
src/Doctor/__Tests/StellaOps.Doctor.WebService.Tests/
|
||||
├── Endpoints/
|
||||
│ ├── ChecksEndpointsTests.cs
|
||||
│ ├── RunEndpointsTests.cs
|
||||
│ └── ReportsEndpointsTests.cs
|
||||
└── Services/
|
||||
├── DoctorRunServiceTests.cs
|
||||
└── ReportStorageServiceTests.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Sprint)
|
||||
|
||||
- [x] All endpoints implemented
|
||||
- [x] SSE streaming for progress
|
||||
- [x] Report storage (in-memory; PostgreSQL planned)
|
||||
- [x] OpenAPI documentation
|
||||
- [x] Authorization on endpoints
|
||||
- [x] Test coverage (22 tests passing)
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date | Entry |
|
||||
|------|-------|
|
||||
| 12-Jan-2026 | Sprint created |
|
||||
| 12-Jan-2026 | Implemented Doctor API WebService with all endpoints |
|
||||
| 12-Jan-2026 | Created DoctorRunService with background run execution |
|
||||
| 12-Jan-2026 | Created InMemoryReportStorageService |
|
||||
| 12-Jan-2026 | Created test project with 22 passing tests |
|
||||
| 12-Jan-2026 | Sprint completed |
|
||||
@@ -1,746 +0,0 @@
|
||||
# SPRINT: Doctor Dashboard - Angular UI Implementation
|
||||
|
||||
> **Implementation ID:** 20260112
|
||||
> **Sprint ID:** 001_008
|
||||
> **Module:** FE (Frontend)
|
||||
> **Status:** DONE
|
||||
> **Created:** 12-Jan-2026
|
||||
> **Depends On:** 001_007 (API Endpoints)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implement the Doctor Dashboard in the Angular web application, providing an interactive UI for running diagnostics, viewing results, and executing remediation commands.
|
||||
|
||||
---
|
||||
|
||||
## Working Directory
|
||||
|
||||
```
|
||||
src/Web/StellaOps.Web/src/app/features/doctor/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Route
|
||||
|
||||
```
|
||||
/ops/doctor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
### Task 1: Feature Module Structure
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```
|
||||
src/app/features/doctor/
|
||||
├── doctor.routes.ts
|
||||
├── doctor-dashboard.component.ts
|
||||
├── doctor-dashboard.component.html
|
||||
├── doctor-dashboard.component.scss
|
||||
├── components/
|
||||
│ ├── check-list/
|
||||
│ │ ├── check-list.component.ts
|
||||
│ │ ├── check-list.component.html
|
||||
│ │ └── check-list.component.scss
|
||||
│ ├── check-result/
|
||||
│ │ ├── check-result.component.ts
|
||||
│ │ ├── check-result.component.html
|
||||
│ │ └── check-result.component.scss
|
||||
│ ├── remediation-panel/
|
||||
│ │ ├── remediation-panel.component.ts
|
||||
│ │ ├── remediation-panel.component.html
|
||||
│ │ └── remediation-panel.component.scss
|
||||
│ ├── evidence-viewer/
|
||||
│ │ ├── evidence-viewer.component.ts
|
||||
│ │ └── evidence-viewer.component.html
|
||||
│ ├── summary-strip/
|
||||
│ │ ├── summary-strip.component.ts
|
||||
│ │ └── summary-strip.component.html
|
||||
│ └── export-dialog/
|
||||
│ ├── export-dialog.component.ts
|
||||
│ └── export-dialog.component.html
|
||||
├── services/
|
||||
│ ├── doctor.client.ts
|
||||
│ ├── doctor.service.ts
|
||||
│ └── doctor.store.ts
|
||||
└── models/
|
||||
├── check-result.model.ts
|
||||
├── doctor-report.model.ts
|
||||
└── remediation.model.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Routes Configuration
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```typescript
|
||||
// doctor.routes.ts
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const DOCTOR_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./doctor-dashboard.component').then(m => m.DoctorDashboardComponent),
|
||||
title: 'Doctor Diagnostics',
|
||||
data: {
|
||||
requiredScopes: ['doctor:run']
|
||||
}
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
Register in main routes:
|
||||
|
||||
```typescript
|
||||
// app.routes.ts
|
||||
{
|
||||
path: 'ops/doctor',
|
||||
loadChildren: () => import('./features/doctor/doctor.routes').then(m => m.DOCTOR_ROUTES),
|
||||
canActivate: [authGuard]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: API Client
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```typescript
|
||||
// services/doctor.client.ts
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '@env/environment';
|
||||
|
||||
export interface CheckMetadata {
|
||||
checkId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
pluginId: string;
|
||||
category: string;
|
||||
defaultSeverity: string;
|
||||
tags: string[];
|
||||
estimatedDurationMs: number;
|
||||
}
|
||||
|
||||
export interface RunDoctorRequest {
|
||||
mode: 'quick' | 'normal' | 'full';
|
||||
categories?: string[];
|
||||
plugins?: string[];
|
||||
checkIds?: string[];
|
||||
timeoutMs?: number;
|
||||
parallelism?: number;
|
||||
includeRemediation?: boolean;
|
||||
}
|
||||
|
||||
export interface DoctorReport {
|
||||
runId: string;
|
||||
status: string;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
summary: DoctorSummary;
|
||||
overallSeverity: string;
|
||||
results: CheckResult[];
|
||||
}
|
||||
|
||||
export interface DoctorSummary {
|
||||
passed: number;
|
||||
info: number;
|
||||
warnings: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
checkId: string;
|
||||
pluginId: string;
|
||||
category: string;
|
||||
severity: string;
|
||||
diagnosis: string;
|
||||
evidence: Evidence;
|
||||
likelyCauses?: string[];
|
||||
remediation?: Remediation;
|
||||
verificationCommand?: string;
|
||||
durationMs: number;
|
||||
executedAt: string;
|
||||
}
|
||||
|
||||
export interface Evidence {
|
||||
description: string;
|
||||
data: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Remediation {
|
||||
requiresBackup: boolean;
|
||||
safetyNote?: string;
|
||||
steps: RemediationStep[];
|
||||
}
|
||||
|
||||
export interface RemediationStep {
|
||||
order: number;
|
||||
description: string;
|
||||
command: string;
|
||||
commandType: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DoctorClient {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = `${environment.apiUrl}/api/v1/doctor`;
|
||||
|
||||
listChecks(category?: string, plugin?: string): Observable<{ checks: CheckMetadata[]; total: number }> {
|
||||
const params: Record<string, string> = {};
|
||||
if (category) params['category'] = category;
|
||||
if (plugin) params['plugin'] = plugin;
|
||||
return this.http.get<{ checks: CheckMetadata[]; total: number }>(`${this.baseUrl}/checks`, { params });
|
||||
}
|
||||
|
||||
listPlugins(): Observable<{ plugins: any[]; total: number }> {
|
||||
return this.http.get<{ plugins: any[]; total: number }>(`${this.baseUrl}/plugins`);
|
||||
}
|
||||
|
||||
startRun(request: RunDoctorRequest): Observable<{ runId: string }> {
|
||||
return this.http.post<{ runId: string }>(`${this.baseUrl}/run`, request);
|
||||
}
|
||||
|
||||
getRunResult(runId: string): Observable<DoctorReport> {
|
||||
return this.http.get<DoctorReport>(`${this.baseUrl}/run/${runId}`);
|
||||
}
|
||||
|
||||
streamRunProgress(runId: string): Observable<MessageEvent> {
|
||||
return new Observable(observer => {
|
||||
const eventSource = new EventSource(`${this.baseUrl}/run/${runId}/stream`);
|
||||
|
||||
eventSource.onmessage = event => observer.next(event);
|
||||
eventSource.onerror = error => observer.error(error);
|
||||
|
||||
return () => eventSource.close();
|
||||
});
|
||||
}
|
||||
|
||||
listReports(limit = 20, offset = 0): Observable<{ reports: DoctorReport[]; total: number }> {
|
||||
return this.http.get<{ reports: DoctorReport[]; total: number }>(
|
||||
`${this.baseUrl}/reports`,
|
||||
{ params: { limit: limit.toString(), offset: offset.toString() } }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: State Store (Signal-based)
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```typescript
|
||||
// services/doctor.store.ts
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
import { CheckResult, DoctorReport, DoctorSummary } from './doctor.client';
|
||||
|
||||
export type DoctorState = 'idle' | 'running' | 'completed' | 'error';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DoctorStore {
|
||||
// State signals
|
||||
readonly state = signal<DoctorState>('idle');
|
||||
readonly currentRunId = signal<string | null>(null);
|
||||
readonly report = signal<DoctorReport | null>(null);
|
||||
readonly progress = signal<{ completed: number; total: number }>({ completed: 0, total: 0 });
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
// Filter signals
|
||||
readonly categoryFilter = signal<string | null>(null);
|
||||
readonly severityFilter = signal<string[]>([]);
|
||||
readonly searchQuery = signal<string>('');
|
||||
|
||||
// Computed values
|
||||
readonly summary = computed<DoctorSummary | null>(() => this.report()?.summary ?? null);
|
||||
|
||||
readonly filteredResults = computed<CheckResult[]>(() => {
|
||||
const report = this.report();
|
||||
if (!report) return [];
|
||||
|
||||
let results = report.results;
|
||||
|
||||
// Filter by category
|
||||
const category = this.categoryFilter();
|
||||
if (category) {
|
||||
results = results.filter(r => r.category === category);
|
||||
}
|
||||
|
||||
// Filter by severity
|
||||
const severities = this.severityFilter();
|
||||
if (severities.length > 0) {
|
||||
results = results.filter(r => severities.includes(r.severity));
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
const query = this.searchQuery().toLowerCase();
|
||||
if (query) {
|
||||
results = results.filter(r =>
|
||||
r.checkId.toLowerCase().includes(query) ||
|
||||
r.diagnosis.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
readonly failedResults = computed(() =>
|
||||
this.report()?.results.filter(r => r.severity === 'fail') ?? []
|
||||
);
|
||||
|
||||
readonly warningResults = computed(() =>
|
||||
this.report()?.results.filter(r => r.severity === 'warn') ?? []
|
||||
);
|
||||
|
||||
// Actions
|
||||
startRun(runId: string, total: number) {
|
||||
this.state.set('running');
|
||||
this.currentRunId.set(runId);
|
||||
this.progress.set({ completed: 0, total });
|
||||
this.error.set(null);
|
||||
}
|
||||
|
||||
updateProgress(completed: number, total: number) {
|
||||
this.progress.set({ completed, total });
|
||||
}
|
||||
|
||||
completeRun(report: DoctorReport) {
|
||||
this.state.set('completed');
|
||||
this.report.set(report);
|
||||
}
|
||||
|
||||
setError(error: string) {
|
||||
this.state.set('error');
|
||||
this.error.set(error);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.state.set('idle');
|
||||
this.currentRunId.set(null);
|
||||
this.report.set(null);
|
||||
this.progress.set({ completed: 0, total: 0 });
|
||||
this.error.set(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Dashboard Component
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```typescript
|
||||
// doctor-dashboard.component.ts
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DoctorClient, RunDoctorRequest } from './services/doctor.client';
|
||||
import { DoctorStore } from './services/doctor.store';
|
||||
import { CheckListComponent } from './components/check-list/check-list.component';
|
||||
import { SummaryStripComponent } from './components/summary-strip/summary-strip.component';
|
||||
import { CheckResultComponent } from './components/check-result/check-result.component';
|
||||
import { ExportDialogComponent } from './components/export-dialog/export-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-doctor-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
CheckListComponent,
|
||||
SummaryStripComponent,
|
||||
CheckResultComponent,
|
||||
ExportDialogComponent
|
||||
],
|
||||
templateUrl: './doctor-dashboard.component.html',
|
||||
styleUrls: ['./doctor-dashboard.component.scss']
|
||||
})
|
||||
export class DoctorDashboardComponent implements OnInit {
|
||||
private readonly client = inject(DoctorClient);
|
||||
readonly store = inject(DoctorStore);
|
||||
|
||||
showExportDialog = false;
|
||||
selectedResult: CheckResult | null = null;
|
||||
|
||||
ngOnInit() {
|
||||
// Load previous report if available
|
||||
}
|
||||
|
||||
runQuickCheck() {
|
||||
this.runDoctor({ mode: 'quick' });
|
||||
}
|
||||
|
||||
runFullCheck() {
|
||||
this.runDoctor({ mode: 'full' });
|
||||
}
|
||||
|
||||
private runDoctor(request: RunDoctorRequest) {
|
||||
this.client.startRun(request).subscribe({
|
||||
next: ({ runId }) => {
|
||||
this.store.startRun(runId, 0);
|
||||
this.pollForResults(runId);
|
||||
},
|
||||
error: err => this.store.setError(err.message)
|
||||
});
|
||||
}
|
||||
|
||||
private pollForResults(runId: string) {
|
||||
// Use SSE for real-time updates
|
||||
this.client.streamRunProgress(runId).subscribe({
|
||||
next: event => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.eventType === 'check-completed') {
|
||||
this.store.updateProgress(data.completed, data.total);
|
||||
} else if (data.eventType === 'run-completed') {
|
||||
this.loadFinalResult(runId);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Fallback to polling if SSE fails
|
||||
this.pollWithInterval(runId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private pollWithInterval(runId: string) {
|
||||
const interval = setInterval(() => {
|
||||
this.client.getRunResult(runId).subscribe(result => {
|
||||
if (result.status === 'completed') {
|
||||
clearInterval(interval);
|
||||
this.store.completeRun(result);
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private loadFinalResult(runId: string) {
|
||||
this.client.getRunResult(runId).subscribe({
|
||||
next: result => this.store.completeRun(result),
|
||||
error: err => this.store.setError(err.message)
|
||||
});
|
||||
}
|
||||
|
||||
openExportDialog() {
|
||||
this.showExportDialog = true;
|
||||
}
|
||||
|
||||
selectResult(result: CheckResult) {
|
||||
this.selectedResult = result;
|
||||
}
|
||||
|
||||
rerunCheck(checkId: string) {
|
||||
this.runDoctor({ mode: 'normal', checkIds: [checkId] });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Dashboard Template
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```html
|
||||
<!-- doctor-dashboard.component.html -->
|
||||
<div class="doctor-dashboard">
|
||||
<header class="dashboard-header">
|
||||
<h1>Doctor Diagnostics</h1>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
(click)="runQuickCheck()"
|
||||
[disabled]="store.state() === 'running'">
|
||||
Run Quick Check
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
(click)="runFullCheck()"
|
||||
[disabled]="store.state() === 'running'">
|
||||
Run Full Check
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline"
|
||||
(click)="openExportDialog()"
|
||||
[disabled]="!store.report()">
|
||||
Export Report
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<select [(ngModel)]="store.categoryFilter" class="filter-select">
|
||||
<option [ngValue]="null">All Categories</option>
|
||||
<option value="core">Core</option>
|
||||
<option value="database">Database</option>
|
||||
<option value="servicegraph">Service Graph</option>
|
||||
<option value="integration">Integration</option>
|
||||
<option value="security">Security</option>
|
||||
<option value="observability">Observability</option>
|
||||
</select>
|
||||
|
||||
<div class="severity-filters">
|
||||
<label>
|
||||
<input type="checkbox" value="fail" (change)="toggleSeverity('fail')"> Failed
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="warn" (change)="toggleSeverity('warn')"> Warnings
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="pass" (change)="toggleSeverity('pass')"> Passed
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search checks..."
|
||||
class="search-input"
|
||||
[(ngModel)]="store.searchQuery">
|
||||
</div>
|
||||
|
||||
<!-- Progress (when running) -->
|
||||
@if (store.state() === 'running') {
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
[style.width.%]="(store.progress().completed / store.progress().total) * 100">
|
||||
</div>
|
||||
<span class="progress-text">
|
||||
{{ store.progress().completed }} / {{ store.progress().total }} checks completed
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Summary Strip -->
|
||||
@if (store.summary(); as summary) {
|
||||
<app-summary-strip [summary]="summary" [duration]="store.report()?.durationMs" />
|
||||
}
|
||||
|
||||
<!-- Results List -->
|
||||
<div class="results-container">
|
||||
<div class="results-list">
|
||||
@for (result of store.filteredResults(); track result.checkId) {
|
||||
<app-check-result
|
||||
[result]="result"
|
||||
[expanded]="selectedResult?.checkId === result.checkId"
|
||||
(click)="selectResult(result)"
|
||||
(rerun)="rerunCheck(result.checkId)" />
|
||||
}
|
||||
|
||||
@if (store.filteredResults().length === 0 && store.state() === 'completed') {
|
||||
<div class="empty-state">
|
||||
No checks match your filters
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Dialog -->
|
||||
@if (showExportDialog) {
|
||||
<app-export-dialog
|
||||
[report]="store.report()!"
|
||||
(close)="showExportDialog = false" />
|
||||
}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Check Result Component
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```typescript
|
||||
// components/check-result/check-result.component.ts
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CheckResult } from '../../services/doctor.client';
|
||||
import { RemediationPanelComponent } from '../remediation-panel/remediation-panel.component';
|
||||
import { EvidenceViewerComponent } from '../evidence-viewer/evidence-viewer.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-check-result',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RemediationPanelComponent, EvidenceViewerComponent],
|
||||
templateUrl: './check-result.component.html',
|
||||
styleUrls: ['./check-result.component.scss']
|
||||
})
|
||||
export class CheckResultComponent {
|
||||
@Input({ required: true }) result!: CheckResult;
|
||||
@Input() expanded = false;
|
||||
@Output() rerun = new EventEmitter<void>();
|
||||
|
||||
get severityClass(): string {
|
||||
return `severity-${this.result.severity}`;
|
||||
}
|
||||
|
||||
get severityIcon(): string {
|
||||
switch (this.result.severity) {
|
||||
case 'pass': return 'check-circle';
|
||||
case 'info': return 'info-circle';
|
||||
case 'warn': return 'alert-triangle';
|
||||
case 'fail': return 'x-circle';
|
||||
case 'skip': return 'skip-forward';
|
||||
default: return 'help-circle';
|
||||
}
|
||||
}
|
||||
|
||||
copyCommand(command: string) {
|
||||
navigator.clipboard.writeText(command);
|
||||
}
|
||||
|
||||
onRerun() {
|
||||
this.rerun.emit();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Remediation Panel Component
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```typescript
|
||||
// components/remediation-panel/remediation-panel.component.ts
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Remediation } from '../../services/doctor.client';
|
||||
|
||||
@Component({
|
||||
selector: 'app-remediation-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="remediation-panel">
|
||||
@if (remediation.safetyNote) {
|
||||
<div class="safety-note">
|
||||
<span class="icon">!</span>
|
||||
{{ remediation.safetyNote }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (likelyCauses?.length) {
|
||||
<div class="likely-causes">
|
||||
<h4>Likely Causes</h4>
|
||||
<ol>
|
||||
@for (cause of likelyCauses; track $index) {
|
||||
<li>{{ cause }}</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="fix-steps">
|
||||
<h4>Fix Steps <button class="copy-all" (click)="copyAll()">Copy All</button></h4>
|
||||
@for (step of remediation.steps; track step.order) {
|
||||
<div class="step">
|
||||
<div class="step-header">
|
||||
<span class="step-number">{{ step.order }}.</span>
|
||||
<span class="step-description">{{ step.description }}</span>
|
||||
<button class="copy-btn" (click)="copy(step.command)">Copy</button>
|
||||
</div>
|
||||
<pre class="step-command"><code>{{ step.command }}</code></pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (verificationCommand) {
|
||||
<div class="verification">
|
||||
<h4>Verification</h4>
|
||||
<pre class="verification-command">
|
||||
<code>{{ verificationCommand }}</code>
|
||||
<button class="copy-btn" (click)="copy(verificationCommand)">Copy</button>
|
||||
</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styleUrls: ['./remediation-panel.component.scss']
|
||||
})
|
||||
export class RemediationPanelComponent {
|
||||
@Input({ required: true }) remediation!: Remediation;
|
||||
@Input() likelyCauses?: string[];
|
||||
@Input() verificationCommand?: string;
|
||||
|
||||
copy(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
copyAll() {
|
||||
const allCommands = this.remediation.steps
|
||||
.map(s => `# ${s.order}. ${s.description}\n${s.command}`)
|
||||
.join('\n\n');
|
||||
navigator.clipboard.writeText(allCommands);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Test Suite
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```
|
||||
src/app/features/doctor/__tests__/
|
||||
├── doctor-dashboard.component.spec.ts
|
||||
├── doctor.client.spec.ts
|
||||
├── doctor.store.spec.ts
|
||||
└── components/
|
||||
├── check-result.component.spec.ts
|
||||
└── remediation-panel.component.spec.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Sprint)
|
||||
|
||||
- [x] Dashboard accessible at /ops/doctor
|
||||
- [x] Quick and Full check buttons work
|
||||
- [x] Real-time progress via SSE
|
||||
- [x] Results display with severity icons
|
||||
- [x] Filtering by category, severity, search
|
||||
- [x] Expandable check results with evidence
|
||||
- [x] Remediation panel with copy buttons
|
||||
- [x] Export dialog for JSON/Markdown
|
||||
- [x] Responsive design for mobile
|
||||
- [x] Test coverage >= 80%
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date | Entry |
|
||||
|------|-------|
|
||||
| 12-Jan-2026 | Sprint created |
|
||||
| 12-Jan-2026 | Created feature module structure with standalone components |
|
||||
| 12-Jan-2026 | Implemented models/doctor.models.ts with all TypeScript interfaces |
|
||||
| 12-Jan-2026 | Implemented services/doctor.client.ts with InjectionToken pattern (HttpDoctorClient, MockDoctorClient) |
|
||||
| 12-Jan-2026 | Implemented services/doctor.store.ts with signal-based state management |
|
||||
| 12-Jan-2026 | Implemented doctor-dashboard.component.ts/html/scss with Angular 17+ control flow |
|
||||
| 12-Jan-2026 | Implemented summary-strip component for check summary display |
|
||||
| 12-Jan-2026 | Implemented check-result component with expandable details |
|
||||
| 12-Jan-2026 | Implemented remediation-panel component with copy-to-clipboard |
|
||||
| 12-Jan-2026 | Implemented evidence-viewer component for data display |
|
||||
| 12-Jan-2026 | Implemented export-dialog component (JSON, Markdown, Plain Text) |
|
||||
| 12-Jan-2026 | Added doctor routes to app.routes.ts at /ops/doctor |
|
||||
| 12-Jan-2026 | Registered DOCTOR_API provider in app.config.ts with quickstartMode toggle |
|
||||
| 12-Jan-2026 | Created test suites for store, dashboard, and summary-strip components |
|
||||
| 12-Jan-2026 | Sprint completed |
|
||||
@@ -1,635 +0,0 @@
|
||||
# SPRINT: Doctor Self-Service Features
|
||||
|
||||
> **Implementation ID:** 20260112
|
||||
> **Sprint ID:** 001_009
|
||||
> **Module:** LB (Library)
|
||||
> **Status:** DONE
|
||||
> **Created:** 12-Jan-2026
|
||||
> **Depends On:** 001_006 (CLI)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implement self-service features that make the Doctor system truly useful for operators without requiring support escalation:
|
||||
|
||||
1. **Export & Share** - Generate shareable diagnostic bundles for support tickets
|
||||
2. **Scheduled Checks** - Run doctor checks on a schedule with alerting
|
||||
3. **Observability Plugin** - OTLP, logs, and metrics checks
|
||||
4. **Auto-Remediation Suggestions** - Context-aware fix recommendations
|
||||
|
||||
---
|
||||
|
||||
## Working Directory
|
||||
|
||||
```
|
||||
src/__Libraries/StellaOps.Doctor/
|
||||
src/Doctor/__Plugins/StellaOps.Doctor.Plugin.Observability/
|
||||
src/Scheduler/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
### Task 1: Export Bundle Generator
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
Generate comprehensive diagnostic bundles for support tickets.
|
||||
|
||||
```csharp
|
||||
// Export/DiagnosticBundleGenerator.cs
|
||||
public sealed class DiagnosticBundleGenerator
|
||||
{
|
||||
private readonly DoctorEngine _engine;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DiagnosticBundleGenerator(
|
||||
DoctorEngine engine,
|
||||
IConfiguration configuration,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_engine = engine;
|
||||
_configuration = configuration;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<DiagnosticBundle> GenerateAsync(
|
||||
DiagnosticBundleOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var report = await _engine.RunAsync(
|
||||
new DoctorRunOptions { Mode = DoctorRunMode.Full },
|
||||
cancellationToken: ct);
|
||||
|
||||
var bundle = new DiagnosticBundle
|
||||
{
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
Version = GetVersion(),
|
||||
Environment = GetEnvironmentInfo(),
|
||||
DoctorReport = report,
|
||||
Configuration = options.IncludeConfig ? GetSanitizedConfig() : null,
|
||||
Logs = options.IncludeLogs ? await CollectLogsAsync(options.LogDuration, ct) : null,
|
||||
SystemInfo = await CollectSystemInfoAsync(ct)
|
||||
};
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public async Task<string> ExportToZipAsync(
|
||||
DiagnosticBundle bundle,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var zipStream = File.Create(outputPath);
|
||||
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Create);
|
||||
|
||||
// Add doctor report
|
||||
await AddJsonEntry(archive, "doctor-report.json", bundle.DoctorReport, ct);
|
||||
|
||||
// Add markdown summary
|
||||
var markdownFormatter = new MarkdownReportFormatter();
|
||||
var markdown = markdownFormatter.FormatReport(bundle.DoctorReport, new ReportFormatOptions
|
||||
{
|
||||
Verbose = true,
|
||||
IncludeRemediation = true
|
||||
});
|
||||
await AddTextEntry(archive, "doctor-report.md", markdown, ct);
|
||||
|
||||
// Add environment info
|
||||
await AddJsonEntry(archive, "environment.json", bundle.Environment, ct);
|
||||
|
||||
// Add system info
|
||||
await AddJsonEntry(archive, "system-info.json", bundle.SystemInfo, ct);
|
||||
|
||||
// Add sanitized config if included
|
||||
if (bundle.Configuration is not null)
|
||||
{
|
||||
await AddJsonEntry(archive, "config-sanitized.json", bundle.Configuration, ct);
|
||||
}
|
||||
|
||||
// Add logs if included
|
||||
if (bundle.Logs is not null)
|
||||
{
|
||||
foreach (var (name, content) in bundle.Logs)
|
||||
{
|
||||
await AddTextEntry(archive, $"logs/{name}", content, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Add README
|
||||
await AddTextEntry(archive, "README.md", GenerateReadme(bundle), ct);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
private EnvironmentInfo GetEnvironmentInfo() => new()
|
||||
{
|
||||
Hostname = Environment.MachineName,
|
||||
Platform = RuntimeInformation.OSDescription,
|
||||
DotNetVersion = Environment.Version.ToString(),
|
||||
ProcessId = Environment.ProcessId,
|
||||
WorkingDirectory = Environment.CurrentDirectory,
|
||||
StartTime = Process.GetCurrentProcess().StartTime.ToUniversalTime()
|
||||
};
|
||||
|
||||
private async Task<SystemInfo> CollectSystemInfoAsync(CancellationToken ct)
|
||||
{
|
||||
var gcInfo = GC.GetGCMemoryInfo();
|
||||
var process = Process.GetCurrentProcess();
|
||||
|
||||
return new SystemInfo
|
||||
{
|
||||
TotalMemoryBytes = gcInfo.TotalAvailableMemoryBytes,
|
||||
ProcessMemoryBytes = process.WorkingSet64,
|
||||
ProcessorCount = Environment.ProcessorCount,
|
||||
Uptime = _timeProvider.GetUtcNow() - process.StartTime.ToUniversalTime()
|
||||
};
|
||||
}
|
||||
|
||||
private SanitizedConfiguration GetSanitizedConfig()
|
||||
{
|
||||
var sanitizer = new ConfigurationSanitizer();
|
||||
return sanitizer.Sanitize(_configuration);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> CollectLogsAsync(
|
||||
TimeSpan duration,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var logs = new Dictionary<string, string>();
|
||||
var logPaths = new[]
|
||||
{
|
||||
"/var/log/stellaops/gateway.log",
|
||||
"/var/log/stellaops/scanner.log",
|
||||
"/var/log/stellaops/orchestrator.log"
|
||||
};
|
||||
|
||||
foreach (var path in logPaths)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var content = await ReadRecentLinesAsync(path, 1000, ct);
|
||||
logs[Path.GetFileName(path)] = content;
|
||||
}
|
||||
}
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
private static string GenerateReadme(DiagnosticBundle bundle) => $"""
|
||||
# Stella Ops Diagnostic Bundle
|
||||
|
||||
Generated: {bundle.GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC
|
||||
Version: {bundle.Version}
|
||||
Hostname: {bundle.Environment.Hostname}
|
||||
|
||||
## Contents
|
||||
|
||||
- `doctor-report.json` - Full diagnostic check results
|
||||
- `doctor-report.md` - Human-readable report
|
||||
- `environment.json` - Environment information
|
||||
- `system-info.json` - System resource information
|
||||
- `config-sanitized.json` - Sanitized configuration (if included)
|
||||
- `logs/` - Recent log files (if included)
|
||||
|
||||
## Summary
|
||||
|
||||
- Passed: {bundle.DoctorReport.Summary.Passed}
|
||||
- Warnings: {bundle.DoctorReport.Summary.Warnings}
|
||||
- Failed: {bundle.DoctorReport.Summary.Failed}
|
||||
|
||||
## How to Use
|
||||
|
||||
Share this bundle with Stella Ops support by:
|
||||
1. Creating a support ticket at https://support.stellaops.org
|
||||
2. Attaching this ZIP file
|
||||
3. Including any additional context about the issue
|
||||
|
||||
**Note:** This bundle has been sanitized to remove sensitive data.
|
||||
Review contents before sharing externally.
|
||||
""";
|
||||
}
|
||||
|
||||
public sealed record DiagnosticBundle
|
||||
{
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required EnvironmentInfo Environment { get; init; }
|
||||
public required DoctorReport DoctorReport { get; init; }
|
||||
public SanitizedConfiguration? Configuration { get; init; }
|
||||
public Dictionary<string, string>? Logs { get; init; }
|
||||
public required SystemInfo SystemInfo { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DiagnosticBundleOptions
|
||||
{
|
||||
public bool IncludeConfig { get; init; } = true;
|
||||
public bool IncludeLogs { get; init; } = true;
|
||||
public TimeSpan LogDuration { get; init; } = TimeSpan.FromHours(1);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: CLI Export Command
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
Add export subcommand to doctor:
|
||||
|
||||
```bash
|
||||
# Generate diagnostic bundle
|
||||
stella doctor export --output diagnostic-bundle.zip
|
||||
|
||||
# Include logs from last 4 hours
|
||||
stella doctor export --output bundle.zip --include-logs --log-duration 4h
|
||||
|
||||
# Exclude configuration
|
||||
stella doctor export --output bundle.zip --no-config
|
||||
```
|
||||
|
||||
```csharp
|
||||
// In DoctorCommandGroup.cs
|
||||
var exportCommand = new Command("export", "Generate diagnostic bundle for support")
|
||||
{
|
||||
outputOption,
|
||||
includeLogsOption,
|
||||
logDurationOption,
|
||||
noConfigOption
|
||||
};
|
||||
exportCommand.SetHandler(DoctorCommandHandlers.ExportAsync);
|
||||
command.AddCommand(exportCommand);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Scheduled Doctor Checks
|
||||
|
||||
**Status:** DEFERRED (requires Scheduler service integration)
|
||||
|
||||
Integrate doctor runs with the Scheduler service.
|
||||
|
||||
```csharp
|
||||
// Scheduled/DoctorScheduleTask.cs
|
||||
public sealed class DoctorScheduleTask : IScheduledTask
|
||||
{
|
||||
public string TaskType => "doctor-check";
|
||||
public string DisplayName => "Scheduled Doctor Check";
|
||||
|
||||
private readonly DoctorEngine _engine;
|
||||
private readonly INotificationService _notifications;
|
||||
private readonly IReportStorageService _storage;
|
||||
|
||||
public DoctorScheduleTask(
|
||||
DoctorEngine engine,
|
||||
INotificationService notifications,
|
||||
IReportStorageService storage)
|
||||
{
|
||||
_engine = engine;
|
||||
_notifications = notifications;
|
||||
_storage = storage;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(
|
||||
ScheduledTaskContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var options = context.GetOptions<DoctorScheduleOptions>();
|
||||
|
||||
var report = await _engine.RunAsync(
|
||||
new DoctorRunOptions
|
||||
{
|
||||
Mode = options.Mode,
|
||||
Categories = options.Categories?.ToImmutableArray()
|
||||
},
|
||||
cancellationToken: ct);
|
||||
|
||||
// Store report
|
||||
await _storage.StoreReportAsync(report, ct);
|
||||
|
||||
// Send notifications based on severity
|
||||
if (report.OverallSeverity >= DoctorSeverity.Warn)
|
||||
{
|
||||
await NotifyAsync(report, options, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NotifyAsync(
|
||||
DoctorReport report,
|
||||
DoctorScheduleOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var notification = new DoctorAlertNotification
|
||||
{
|
||||
Severity = report.OverallSeverity,
|
||||
Summary = $"Doctor found {report.Summary.Failed} failures, {report.Summary.Warnings} warnings",
|
||||
ReportId = report.RunId,
|
||||
FailedChecks = report.Results
|
||||
.Where(r => r.Severity == DoctorSeverity.Fail)
|
||||
.Select(r => r.CheckId)
|
||||
.ToList()
|
||||
};
|
||||
|
||||
foreach (var channel in options.NotificationChannels)
|
||||
{
|
||||
await _notifications.SendAsync(channel, notification, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DoctorScheduleOptions
|
||||
{
|
||||
public DoctorRunMode Mode { get; init; } = DoctorRunMode.Quick;
|
||||
public IReadOnlyList<string>? Categories { get; init; }
|
||||
public IReadOnlyList<string> NotificationChannels { get; init; } = ["slack", "email"];
|
||||
public DoctorSeverity NotifyOnSeverity { get; init; } = DoctorSeverity.Warn;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: CLI Schedule Command
|
||||
|
||||
**Status:** DEFERRED (requires Task 3)
|
||||
|
||||
```bash
|
||||
# Schedule daily doctor check
|
||||
stella doctor schedule create --name daily-check --cron "0 6 * * *" --mode quick
|
||||
|
||||
# Schedule weekly full check with notifications
|
||||
stella doctor schedule create --name weekly-full \
|
||||
--cron "0 2 * * 0" \
|
||||
--mode full \
|
||||
--notify-channel slack \
|
||||
--notify-on warn,fail
|
||||
|
||||
# List scheduled checks
|
||||
stella doctor schedule list
|
||||
|
||||
# Delete scheduled check
|
||||
stella doctor schedule delete --name daily-check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Observability Plugin
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```
|
||||
StellaOps.Doctor.Plugin.Observability/
|
||||
├── ObservabilityDoctorPlugin.cs
|
||||
├── Checks/
|
||||
│ ├── OtlpEndpointCheck.cs
|
||||
│ ├── LogDirectoryCheck.cs
|
||||
│ ├── LogRotationCheck.cs
|
||||
│ └── PrometheusScapeCheck.cs
|
||||
└── StellaOps.Doctor.Plugin.Observability.csproj
|
||||
```
|
||||
|
||||
**Check Catalog:**
|
||||
|
||||
| CheckId | Name | Severity | Description |
|
||||
|---------|------|----------|-------------|
|
||||
| `check.telemetry.otlp.endpoint` | OTLP Endpoint | Warn | OTLP collector reachable |
|
||||
| `check.logs.directory.writable` | Logs Writable | Fail | Log directory writable |
|
||||
| `check.logs.rotation.configured` | Log Rotation | Warn | Log rotation configured |
|
||||
| `check.metrics.prometheus.scrape` | Prometheus Scrape | Warn | Prometheus can scrape metrics |
|
||||
|
||||
**OtlpEndpointCheck:**
|
||||
|
||||
```csharp
|
||||
public sealed class OtlpEndpointCheck : IDoctorCheck
|
||||
{
|
||||
public string CheckId => "check.telemetry.otlp.endpoint";
|
||||
public string Name => "OTLP Endpoint";
|
||||
public string Description => "Verify OTLP collector endpoint is reachable";
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
public IReadOnlyList<string> Tags => ["observability", "telemetry"];
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
|
||||
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var endpoint = context.Configuration["Telemetry:OtlpEndpoint"];
|
||||
return !string.IsNullOrEmpty(endpoint);
|
||||
}
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(
|
||||
DoctorPluginContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var endpoint = context.Configuration["Telemetry:OtlpEndpoint"]!;
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = context.Services.GetRequiredService<IHttpClientFactory>()
|
||||
.CreateClient("DoctorHealthCheck");
|
||||
|
||||
// OTLP gRPC or HTTP endpoint health check
|
||||
var response = await httpClient.GetAsync($"{endpoint}/v1/health", ct);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Pass("OTLP collector is reachable")
|
||||
.WithEvidence(eb => eb.Add("Endpoint", endpoint))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return context.CreateResult(CheckId)
|
||||
.Warn($"OTLP collector returned {response.StatusCode}")
|
||||
.WithEvidence(eb => eb
|
||||
.Add("Endpoint", endpoint)
|
||||
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"OTLP collector not running",
|
||||
"Network connectivity issue",
|
||||
"Wrong endpoint configured")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check OTLP collector status",
|
||||
"docker logs otel-collector --tail 50",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Test endpoint connectivity",
|
||||
$"curl -v {endpoint}/v1/health",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return context.CreateResult(CheckId)
|
||||
.Warn($"Cannot reach OTLP collector: {ex.Message}")
|
||||
.WithEvidence(eb => eb.Add("Error", ex.Message))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Configuration Sanitizer
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
Safely export configuration without secrets.
|
||||
|
||||
```csharp
|
||||
// Export/ConfigurationSanitizer.cs
|
||||
public sealed class ConfigurationSanitizer
|
||||
{
|
||||
private static readonly HashSet<string> SensitiveKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"password", "secret", "key", "token", "apikey", "api_key",
|
||||
"connectionstring", "connection_string", "credentials"
|
||||
};
|
||||
|
||||
public SanitizedConfiguration Sanitize(IConfiguration configuration)
|
||||
{
|
||||
var result = new Dictionary<string, object>();
|
||||
|
||||
foreach (var section in configuration.GetChildren())
|
||||
{
|
||||
result[section.Key] = SanitizeSection(section);
|
||||
}
|
||||
|
||||
return new SanitizedConfiguration
|
||||
{
|
||||
Values = result,
|
||||
SanitizedKeys = GetSanitizedKeysList(configuration)
|
||||
};
|
||||
}
|
||||
|
||||
private object SanitizeSection(IConfigurationSection section)
|
||||
{
|
||||
if (!section.GetChildren().Any())
|
||||
{
|
||||
// Leaf value
|
||||
if (IsSensitiveKey(section.Key))
|
||||
{
|
||||
return "***REDACTED***";
|
||||
}
|
||||
return section.Value ?? "(null)";
|
||||
}
|
||||
|
||||
// Section with children
|
||||
var result = new Dictionary<string, object>();
|
||||
foreach (var child in section.GetChildren())
|
||||
{
|
||||
result[child.Key] = SanitizeSection(child);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsSensitiveKey(string key)
|
||||
{
|
||||
return SensitiveKeys.Any(s => key.Contains(s, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> GetSanitizedKeysList(IConfiguration configuration)
|
||||
{
|
||||
var keys = new List<string>();
|
||||
CollectSanitizedKeys(configuration, "", keys);
|
||||
return keys;
|
||||
}
|
||||
|
||||
private void CollectSanitizedKeys(IConfiguration config, string prefix, List<string> keys)
|
||||
{
|
||||
foreach (var section in config.GetChildren())
|
||||
{
|
||||
var fullKey = string.IsNullOrEmpty(prefix) ? section.Key : $"{prefix}:{section.Key}";
|
||||
|
||||
if (IsSensitiveKey(section.Key))
|
||||
{
|
||||
keys.Add(fullKey);
|
||||
}
|
||||
|
||||
CollectSanitizedKeys(section, fullKey, keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SanitizedConfiguration
|
||||
{
|
||||
public required Dictionary<string, object> Values { get; init; }
|
||||
public required IReadOnlyList<string> SanitizedKeys { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Test Suite
|
||||
|
||||
**Status:** DONE
|
||||
|
||||
```
|
||||
src/__Tests/__Libraries/StellaOps.Doctor.Tests/
|
||||
├── Export/
|
||||
│ ├── DiagnosticBundleGeneratorTests.cs
|
||||
│ └── ConfigurationSanitizerTests.cs
|
||||
└── Scheduled/
|
||||
└── DoctorScheduleTaskTests.cs
|
||||
|
||||
src/Doctor/__Tests/
|
||||
└── StellaOps.Doctor.Plugin.Observability.Tests/
|
||||
└── Checks/
|
||||
├── OtlpEndpointCheckTests.cs
|
||||
└── LogDirectoryCheckTests.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Commands Summary
|
||||
|
||||
```bash
|
||||
# Export diagnostic bundle
|
||||
stella doctor export --output bundle.zip
|
||||
|
||||
# Schedule checks
|
||||
stella doctor schedule create --name NAME --cron CRON --mode MODE
|
||||
stella doctor schedule list
|
||||
stella doctor schedule delete --name NAME
|
||||
stella doctor schedule run --name NAME
|
||||
|
||||
# View scheduled check history
|
||||
stella doctor schedule history --name NAME --last 10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Sprint)
|
||||
|
||||
- [x] Diagnostic bundle generation with sanitization
|
||||
- [x] Export command in CLI
|
||||
- [ ] Scheduled doctor checks with notifications (DEFERRED)
|
||||
- [x] Observability plugin with 4 checks
|
||||
- [x] Configuration sanitizer removes all secrets
|
||||
- [x] ZIP bundle contains README
|
||||
- [x] Test coverage >= 85%
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date | Entry |
|
||||
|------|-------|
|
||||
| 12-Jan-2026 | Sprint created |
|
||||
| 12-Jan-2026 | Created Export/DiagnosticBundleOptions.cs with bundle options |
|
||||
| 12-Jan-2026 | Created Export/DiagnosticBundle.cs with EnvironmentInfo, SystemInfo, SanitizedConfiguration records |
|
||||
| 12-Jan-2026 | Implemented Export/ConfigurationSanitizer.cs with sensitive key detection |
|
||||
| 12-Jan-2026 | Implemented Export/DiagnosticBundleGenerator.cs with ZIP export functionality |
|
||||
| 12-Jan-2026 | Added DiagnosticBundleGenerator to DI registration |
|
||||
| 12-Jan-2026 | Added export command to DoctorCommandGroup.cs with --output, --include-logs, --log-duration, --no-config options |
|
||||
| 12-Jan-2026 | Created StellaOps.Doctor.Plugin.Observability project |
|
||||
| 12-Jan-2026 | Implemented ObservabilityDoctorPlugin with 4 checks |
|
||||
| 12-Jan-2026 | Implemented OtlpEndpointCheck for OTLP collector health |
|
||||
| 12-Jan-2026 | Implemented LogDirectoryCheck for log directory write access |
|
||||
| 12-Jan-2026 | Implemented LogRotationCheck for log rotation configuration |
|
||||
| 12-Jan-2026 | Implemented PrometheusScrapeCheck for metrics endpoint |
|
||||
| 12-Jan-2026 | Created StellaOps.Doctor.Tests project with ConfigurationSanitizerTests and DiagnosticBundleGeneratorTests |
|
||||
| 12-Jan-2026 | Created StellaOps.Doctor.Plugin.Observability.Tests with plugin and check tests |
|
||||
| 12-Jan-2026 | Tasks 3-4 (Scheduled checks) deferred - requires Scheduler service integration |
|
||||
| 12-Jan-2026 | Sprint substantially complete |
|
||||
@@ -23,20 +23,20 @@
|
||||
## Delivery Tracker
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | AUDIT-HOTLIST-SCANNER-LANG-DOTNET-0001 | TODO | Approved 2026-01-12; Hotlist S3/M1/Q0 | Guild - Scanner | Remediate hotlist findings for `src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.DotNet/StellaOps.Scanner.Analyzers.Lang.DotNet.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 2 | AUDIT-HOTLIST-SCANNER-CONTRACTS-0001 | TODO | Approved 2026-01-12; Hotlist S3/M0/Q0 | Guild - Scanner | Remediate hotlist findings for `src/Scanner/__Libraries/StellaOps.Scanner.Contracts/StellaOps.Scanner.Contracts.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 3 | AUDIT-HOTLIST-CLI-0001 | TODO | Approved 2026-01-12; Hotlist S2/M5/Q3 | Guild - CLI | Remediate hotlist findings for `src/Cli/StellaOps.Cli/StellaOps.Cli.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 4 | AUDIT-HOTLIST-EXPORTCENTER-WEBSERVICE-0001 | TODO | Approved 2026-01-12; Hotlist S2/M4/Q0 | Guild - ExportCenter | Remediate hotlist findings for `src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/StellaOps.ExportCenter.WebService.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 5 | AUDIT-HOTLIST-POLICY-ENGINE-0001 | TODO | Approved 2026-01-12; Hotlist S2/M3/Q2 | Guild - Policy | Remediate hotlist findings for `src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 6 | AUDIT-HOTLIST-SCANNER-NATIVE-0001 | TODO | Approved 2026-01-12; Hotlist S2/M3/Q1 | Guild - Scanner | Remediate hotlist findings for `src/Scanner/StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 7 | AUDIT-HOTLIST-SCANNER-WEBSERVICE-0001 | TODO | Approved 2026-01-12; Hotlist S2/M2/Q2 | Guild - Scanner | Remediate hotlist findings for `src/Scanner/StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 8 | AUDIT-HOTLIST-EXPORTCENTER-CORE-0001 | TODO | Approved 2026-01-12; Hotlist S2/M2/Q1 | Guild - ExportCenter | Remediate hotlist findings for `src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 1 | AUDIT-HOTLIST-SCANNER-LANG-DOTNET-0001 | DONE | Applied 2026-01-12 | Guild - Scanner | Remediate hotlist findings for `src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.DotNet/StellaOps.Scanner.Analyzers.Lang.DotNet.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 2 | AUDIT-HOTLIST-SCANNER-CONTRACTS-0001 | DONE | Applied 2026-01-12 | Guild - Scanner | Remediate hotlist findings for `src/Scanner/__Libraries/StellaOps.Scanner.Contracts/StellaOps.Scanner.Contracts.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 3 | AUDIT-HOTLIST-CLI-0001 | BLOCKED | Blocked: CLI tests under active edit; avoid touching other agent work | Guild - CLI | Remediate hotlist findings for `src/Cli/StellaOps.Cli/StellaOps.Cli.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 4 | AUDIT-HOTLIST-EXPORTCENTER-WEBSERVICE-0001 | DONE | Applied 2026-01-13; tests added and tracker updated | Guild - ExportCenter | Remediate hotlist findings for `src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/StellaOps.ExportCenter.WebService.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 5 | AUDIT-HOTLIST-POLICY-ENGINE-0001 | DONE | Applied 2026-01-13; determinism DI, options binding, auth, tests | Guild - Policy | Remediate hotlist findings for `src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 6 | AUDIT-HOTLIST-SCANNER-NATIVE-0001 | DONE | Applied 2026-01-13; tracker updated | Guild - Scanner | Remediate hotlist findings for `src/Scanner/StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 7 | AUDIT-HOTLIST-SCANNER-WEBSERVICE-0001 | DONE | Applied 2026-01-13; Hotlist S2/M2/Q2 | Guild - Scanner | Remediate hotlist findings for `src/Scanner/StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 8 | AUDIT-HOTLIST-EXPORTCENTER-CORE-0001 | DOING | In progress 2026-01-13; Hotlist S2/M2/Q1 | Guild - ExportCenter | Remediate hotlist findings for `src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 9 | AUDIT-HOTLIST-SIGNALS-0001 | TODO | Approved 2026-01-12; Hotlist S2/M2/Q1 | Guild - Signals | Remediate hotlist findings for `src/Signals/StellaOps.Signals/StellaOps.Signals.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 10 | AUDIT-HOTLIST-SCANNER-LANG-DENO-0001 | TODO | Approved 2026-01-12; Hotlist S2/M0/Q0 | Guild - Scanner | Remediate hotlist findings for `src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Deno/StellaOps.Scanner.Analyzers.Lang.Deno.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 10 | AUDIT-HOTLIST-SCANNER-LANG-DENO-0001 | DONE | Applied 2026-01-13; runtime hardening, determinism fixes, tests updated | Guild - Scanner | Remediate hotlist findings for `src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Deno/StellaOps.Scanner.Analyzers.Lang.Deno.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 11 | AUDIT-HOTLIST-VEXLENS-0001 | TODO | Approved 2026-01-12; Hotlist S1/M4/Q0 | Guild - VexLens | Remediate hotlist findings for `src/VexLens/StellaOps.VexLens/StellaOps.VexLens.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 12 | AUDIT-HOTLIST-CONCELIER-CORE-0001 | TODO | Approved 2026-01-12; Hotlist S1/M3/Q2 | Guild - Concelier | Remediate hotlist findings for `src/Concelier/__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 13 | AUDIT-HOTLIST-SCANNER-REACHABILITY-0001 | TODO | Approved 2026-01-12; Hotlist S1/M3/Q1 | Guild - Scanner | Remediate hotlist findings for `src/Scanner/__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 14 | AUDIT-HOTLIST-EVIDENCE-0001 | TODO | Approved 2026-01-12; Hotlist S1/M3/Q0 | Guild - Core | Remediate hotlist findings for `src/__Libraries/StellaOps.Evidence/StellaOps.Evidence.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 13 | AUDIT-HOTLIST-SCANNER-REACHABILITY-0001 | DONE | Applied 2026-01-13; tracker updated | Guild - Scanner | Remediate hotlist findings for `src/Scanner/__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 14 | AUDIT-HOTLIST-EVIDENCE-0001 | DONE | Applied 2026-01-13 | Guild - Core | Remediate hotlist findings for `src/__Libraries/StellaOps.Evidence/StellaOps.Evidence.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 15 | AUDIT-HOTLIST-ZASTAVA-OBSERVER-0001 | TODO | Approved 2026-01-12; Hotlist S1/M3/Q0 | Guild - Zastava | Remediate hotlist findings for `src/Zastava/StellaOps.Zastava.Observer/StellaOps.Zastava.Observer.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 16 | AUDIT-HOTLIST-TESTKIT-0001 | TODO | Approved 2026-01-12; Hotlist S0/M4/Q1 | Guild - Core | Remediate hotlist findings for `src/__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 17 | AUDIT-HOTLIST-EXCITITOR-WORKER-0001 | TODO | Approved 2026-01-12; Hotlist S0/M4/Q1 | Guild - Excititor | Remediate hotlist findings for `src/Excititor/StellaOps.Excititor.Worker/StellaOps.Excititor.Worker.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
@@ -46,7 +46,7 @@
|
||||
| 21 | AUDIT-HOTLIST-PROVCACHE-0001 | TODO | Approved 2026-01-12; Hotlist S0/M3/Q1 | Guild - Core | Remediate hotlist findings for `src/__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 22 | AUDIT-HOTLIST-EXCITITOR-CORE-0001 | TODO | Approved 2026-01-12; Hotlist Q2/S1/M2 | Guild - Excititor | Remediate hotlist findings for `src/Excititor/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 23 | AUDIT-HOTLIST-SBOMSERVICE-0001 | TODO | Approved 2026-01-12; Hotlist Q2/S1/M2 | Guild - SbomService | Remediate hotlist findings for `src/SbomService/StellaOps.SbomService/StellaOps.SbomService.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 24 | AUDIT-HOTLIST-SCANNER-SBOMER-BUILDX-0001 | TODO | Approved 2026-01-12; Hotlist Q2/S1/M2 | Guild - Scanner | Remediate hotlist findings for `src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/StellaOps.Scanner.Sbomer.BuildXPlugin.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 24 | AUDIT-HOTLIST-SCANNER-SBOMER-BUILDX-0001 | DONE | Applied 2026-01-13; Hotlist Q2/S1/M2 | Guild - Scanner | Remediate hotlist findings for `src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/StellaOps.Scanner.Sbomer.BuildXPlugin.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 25 | AUDIT-HOTLIST-ATTESTOR-WEBSERVICE-0001 | TODO | Approved 2026-01-12; Hotlist Q2/S0/M2 | Guild - Attestor | Remediate hotlist findings for `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/StellaOps.Attestor.WebService.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 26 | AUDIT-HOTLIST-POLICY-TOOLS-0001 | TODO | Approved 2026-01-12; Hotlist Q2/S0/M1 | Guild - Policy | Remediate hotlist findings for `src/__Libraries/StellaOps.Policy.Tools/StellaOps.Policy.Tools.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
| 27 | AUDIT-HOTLIST-SCANNER-SOURCES-0001 | TODO | Approved 2026-01-12; Hotlist Q2/S0/M1 | Guild - Scanner | Remediate hotlist findings for `src/Scanner/__Libraries/StellaOps.Scanner.Sources/StellaOps.Scanner.Sources.csproj`; apply fixes, add tests, update audit tracker. |
|
||||
@@ -85,6 +85,14 @@
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-01-12 | Started AUDIT-HOTLIST-SCANNER-CONTRACTS-0001 remediation work. | Project Mgmt |
|
||||
| 2026-01-12 | Completed AUDIT-HOTLIST-SCANNER-CONTRACTS-0001; updated safe JSON encoding and coverage, updated audit tracker and local TASKS.md. | Project Mgmt |
|
||||
| 2026-01-12 | Started AUDIT-HOTLIST-SCANNER-LANG-DOTNET-0001 remediation work. | Project Mgmt |
|
||||
| 2026-01-12 | Blocked AUDIT-HOTLIST-CLI-0001: CLI tests are being modified by another agent; cannot update tests without touching their work. | Project Mgmt |
|
||||
| 2026-01-12 | Started AUDIT-HOTLIST-EXPORTCENTER-WEBSERVICE-0001 remediation work. | Project Mgmt |
|
||||
| 2026-01-13 | Completed AUDIT-HOTLIST-EXPORTCENTER-WEBSERVICE-0001; determinism/DI guards, retention/TLS gating, tests; updated audit tracker and TASKS.md. | Project Mgmt |
|
||||
| 2026-01-12 | Completed AUDIT-HOTLIST-SCANNER-LANG-DOTNET-0001; applied fixes and tests, updated audit tracker and local TASKS.md. | Project Mgmt |
|
||||
| 2026-01-12 | Test run failed for StellaOps.Scanner.Analyzers.Lang.DotNet.Tests: missing testhost.dll in testhost.deps.json. | Project Mgmt |
|
||||
| 2026-01-12 | Started AUDIT-SLN-NEWPROJECTS-0001 to add missing projects and audit new entries. | Project Mgmt |
|
||||
| 2026-01-12 | Completed AUDIT-SLN-NEWPROJECTS-0001: src/StellaOps.sln synced to include all csproj; Doctor projects audited and recorded in archived tracker findings. | Project Mgmt |
|
||||
| 2026-01-12 | Added Doctor.Tests to src/StellaOps.sln and extended archived audit tracker with audit rows and findings for the new test project. | Project Mgmt |
|
||||
@@ -93,6 +101,22 @@
|
||||
| 2026-01-12 | Expanded Delivery Tracker with per-project hotlist items and batched test/reuse gap remediation tasks. | Project Mgmt |
|
||||
| 2026-01-12 | Set working directory to repo root to cover devops and docs items in test/reuse gaps. | Project Mgmt |
|
||||
| 2026-01-12 | Sprint created to execute approved pending APPLY actions from the C# audit backlog. | Project Mgmt |
|
||||
| 2026-01-12 | Tests failed: StellaOps.Scanner.CallGraph.Tests (ValkeyCallGraphCacheServiceTests null result, BinaryDisassemblyTests target mismatch, BenchmarkIntegrationTests repo root missing). | Project Mgmt |
|
||||
| 2026-01-13 | Started AUDIT-HOTLIST-POLICY-ENGINE-0001 remediation work. | Project Mgmt |
|
||||
| 2026-01-13 | Completed AUDIT-HOTLIST-POLICY-ENGINE-0001 remediation work; updated determinism, auth, options binding, and tests. | Project Mgmt |
|
||||
| 2026-01-13 | Started AUDIT-HOTLIST-SCANNER-NATIVE-0001 remediation work. | Project Mgmt |
|
||||
| 2026-01-13 | Completed AUDIT-HOTLIST-SCANNER-NATIVE-0001; updated native analyzer determinism, hardening, runtime capture, and tests; updated audit tracker. | Project Mgmt |
|
||||
| 2026-01-13 | Started AUDIT-HOTLIST-SCANNER-WEBSERVICE-0001 remediation work. | Project Mgmt |
|
||||
| 2026-01-13 | Completed AUDIT-HOTLIST-SCANNER-WEBSERVICE-0001; DSSE PAE, determinism/auth updates, test fixes; trackers updated. | Project Mgmt |
|
||||
| 2026-01-13 | Started AUDIT-HOTLIST-SCANNER-SBOMER-BUILDX-0001 remediation work. | Project Mgmt |
|
||||
| 2026-01-13 | Completed AUDIT-HOTLIST-SCANNER-SBOMER-BUILDX-0001; canonical surface manifests, HttpClientFactory + TLS guardrails, deterministic tests; trackers updated. | Project Mgmt |
|
||||
| 2026-01-13 | Started AUDIT-HOTLIST-SCANNER-LANG-DENO-0001 remediation work. | Project Mgmt |
|
||||
| 2026-01-13 | Completed AUDIT-HOTLIST-SCANNER-LANG-DENO-0001; runtime hardening, deterministic ordering, safe JSON encoding, tests updated; trackers updated. | Project Mgmt |
|
||||
| 2026-01-13 | Started AUDIT-HOTLIST-SCANNER-REACHABILITY-0001 remediation work. | Project Mgmt |
|
||||
| 2026-01-13 | Completed AUDIT-HOTLIST-SCANNER-REACHABILITY-0001; DSSE PAE/canon, determinism/cancellation fixes, invariant formatting, tests; trackers updated. | Project Mgmt |
|
||||
| 2026-01-13 | Started AUDIT-HOTLIST-EVIDENCE-0001 remediation work. | Project Mgmt |
|
||||
| 2026-01-13 | Completed AUDIT-HOTLIST-EVIDENCE-0001 (determinism, schema validation, budgets, retention, tests). | Project Mgmt |
|
||||
| 2026-01-13 | Started AUDIT-HOTLIST-EXPORTCENTER-CORE-0001 remediation work. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
- APPROVED 2026-01-12: All pending APPLY actions are approved for execution under module review gates.
|
||||
@@ -100,6 +124,7 @@
|
||||
- Cross-module doc link updates applied for archived audit files and the code-of-conduct relocation in docs/code-of-conduct/.
|
||||
- Backlog size (851 TODO APPLY items); mitigate by prioritizing hotlists then long-tail batches.
|
||||
- Devops and docs items are in scope; cross-directory changes must be logged per sprint guidance.
|
||||
- BLOCKED: AUDIT-HOTLIST-CLI-0001 requires edits in `src/Cli/__Tests/StellaOps.Cli.Tests` which are under active modification by another agent; defer until those changes land or ownership is coordinated.
|
||||
|
||||
## Next Checkpoints
|
||||
- TBD: Security hotlist remediation review.
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
# Master Index 20260113 - OCI Layer-Level Binary Integrity Verification
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This master index coordinates four sprint batches implementing **OCI layer-level image integrity verification** with binary patch detection capabilities. The complete feature set enables:
|
||||
|
||||
1. **Multi-arch image inspection** with layer enumeration
|
||||
2. **Section-level binary analysis** (ELF/PE) for vendor backport detection
|
||||
3. **DSSE-signed attestations** proving patch presence or absence
|
||||
4. **VEX auto-linking** to binary evidence for deterministic decisions
|
||||
5. **Golden pairs dataset** for validation and regression testing
|
||||
|
||||
**Total Effort:** ~25-30 story points across 4 batches, 13 sprints
|
||||
**Priority:** High (core differentiator for evidence-first security)
|
||||
|
||||
## Background
|
||||
|
||||
### Advisory Origin
|
||||
|
||||
The original product advisory specified requirements for:
|
||||
|
||||
> OCI layer-level image integrity verification that:
|
||||
> - Enumerates all layers across multi-arch manifests
|
||||
> - Computes section-level hashes (ELF .text/.rodata, PE .text/.rdata)
|
||||
> - Produces DSSE-signed in-toto attestations for binary diffs
|
||||
> - Maps findings to VEX with cryptographic evidence links
|
||||
> - Validates against a curated "golden pairs" corpus
|
||||
|
||||
### Strategic Value
|
||||
|
||||
| Capability | Business Value |
|
||||
|------------|----------------|
|
||||
| Binary patch detection | Prove vendor backports without source access |
|
||||
| Attestation chain | Tamper-evident evidence for audits |
|
||||
| VEX evidence links | Deterministic, reproducible security decisions |
|
||||
| Golden pairs validation | Confidence in detection accuracy |
|
||||
|
||||
## Sprint Batch Index
|
||||
|
||||
| Batch | ID | Topic | Sprints | Status | Priority |
|
||||
|-------|-----|-------|---------|--------|----------|
|
||||
| 1 | 20260113_001 | ELF Section Hashes & Binary Diff Attestation | 4 | TODO | P0 |
|
||||
| 2 | 20260113_002 | Image Index Resolution CLI | 3 | TODO | P1 |
|
||||
| 3 | 20260113_003 | VEX Evidence Auto-Linking | 2 | TODO | P1 |
|
||||
| 4 | 20260113_004 | Golden Pairs Pilot (Vendor Backport Corpus) | 3 | TODO | P2 |
|
||||
|
||||
## Batch Details
|
||||
|
||||
### Batch 001: ELF Section Hashes & Binary Diff Attestation
|
||||
|
||||
**Index:** [SPRINT_20260113_001_000_INDEX_binary_diff_attestation.md](SPRINT_20260113_001_000_INDEX_binary_diff_attestation.md)
|
||||
|
||||
**Scope:** Core binary analysis infrastructure
|
||||
|
||||
| Sprint | ID | Module | Topic | Key Deliverables |
|
||||
|--------|-----|--------|-------|------------------|
|
||||
| 1 | 001_001 | SCANNER | ELF Section Hash Extractor | `IElfSectionHashExtractor`, per-section SHA-256 |
|
||||
| 2 | 001_002 | ATTESTOR | BinaryDiffV1 In-Toto Predicate | `BinaryDiffV1` schema, DSSE signing |
|
||||
| 3 | 001_003 | CLI | Binary Diff Command | `stella binary diff`, OCI layer comparison |
|
||||
| 4 | 001_004 | DOCS | Binary Diff Attestation Documentation | Architecture docs, examples |
|
||||
|
||||
**Key Models:**
|
||||
- `ElfSectionHash` - Per-section hash with flags
|
||||
- `BinaryDiffV1` - In-toto predicate for diff attestations
|
||||
- `SectionDelta` - Section comparison result
|
||||
|
||||
### Batch 002: Image Index Resolution CLI
|
||||
|
||||
**Index:** [SPRINT_20260113_002_000_INDEX_image_index_resolution.md](SPRINT_20260113_002_000_INDEX_image_index_resolution.md)
|
||||
|
||||
**Scope:** Multi-arch image inspection and layer enumeration
|
||||
|
||||
| Sprint | ID | Module | Topic | Key Deliverables |
|
||||
|--------|-----|--------|-------|------------------|
|
||||
| 1 | 002_001 | SCANNER | OCI Image Inspector Service | `IOciImageInspector`, manifest resolution |
|
||||
| 2 | 002_002 | CLI | Image Inspect Command | `stella image inspect`, platform selection |
|
||||
| 3 | 002_003 | DOCS | Image Inspection Documentation | Architecture docs, examples |
|
||||
|
||||
**Key Models:**
|
||||
- `ImageInspectionResult` - Full image analysis
|
||||
- `PlatformManifest` - Per-platform manifest info
|
||||
- `LayerInfo` - Layer digest, size, media type
|
||||
|
||||
### Batch 003: VEX Evidence Auto-Linking
|
||||
|
||||
**Index:** [SPRINT_20260113_003_000_INDEX_vex_evidence_linking.md](SPRINT_20260113_003_000_INDEX_vex_evidence_linking.md)
|
||||
|
||||
**Scope:** Automatic linking of VEX entries to binary diff evidence
|
||||
|
||||
| Sprint | ID | Module | Topic | Key Deliverables |
|
||||
|--------|-----|--------|-------|------------------|
|
||||
| 1 | 003_001 | EXCITITOR | VEX Evidence Linker | `IVexEvidenceLinker`, CycloneDX mapping |
|
||||
| 2 | 003_002 | CLI | VEX Evidence Integration | `--link-evidence` flag, evidence display |
|
||||
|
||||
**Key Models:**
|
||||
- `VexEvidenceLink` - Link to evidence attestation
|
||||
- `VexEvidenceLinkSet` - Multi-evidence aggregation
|
||||
|
||||
### Batch 004: Golden Pairs Pilot
|
||||
|
||||
**Index:** [SPRINT_20260113_004_000_INDEX_golden_pairs_pilot.md](SPRINT_20260113_004_000_INDEX_golden_pairs_pilot.md)
|
||||
|
||||
**Scope:** Validation dataset for binary patch detection
|
||||
|
||||
| Sprint | ID | Module | Topic | Key Deliverables |
|
||||
|--------|-----|--------|-------|------------------|
|
||||
| 1 | 004_001 | TOOLS | Golden Pairs Data Model | `GoldenPairMetadata`, JSON schema |
|
||||
| 2 | 004_002 | TOOLS | Mirror & Diff Pipeline | Package mirror, diff validation |
|
||||
| 3 | 004_003 | TOOLS | Pilot CVE Corpus (3 CVEs) | Dirty Pipe, Baron Samedit, PrintNightmare |
|
||||
|
||||
**Target CVEs:**
|
||||
- CVE-2022-0847 (Dirty Pipe) - Linux kernel
|
||||
- CVE-2021-3156 (Baron Samedit) - sudo
|
||||
- CVE-2021-34527 (PrintNightmare) - Windows PE (conditional)
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
+-----------------------------------------------------------------------------------+
|
||||
| DEPENDENCY FLOW |
|
||||
+-----------------------------------------------------------------------------------+
|
||||
| |
|
||||
| BATCH 001: Binary Diff Attestation |
|
||||
| +------------------------------------------------------------------+ |
|
||||
| | Sprint 001 (ELF Hashes) --> Sprint 002 (Predicate) --> Sprint 003 (CLI) |
|
||||
| +------------------------------------------------------------------+ |
|
||||
| | | |
|
||||
| v v |
|
||||
| BATCH 002: Image Index Resolution | |
|
||||
| +--------------------------------+ | |
|
||||
| | Sprint 001 --> Sprint 002 (CLI)| | |
|
||||
| +--------------------------------+ | |
|
||||
| | | |
|
||||
| v v |
|
||||
| BATCH 003: VEX Evidence Linking <------+ |
|
||||
| +--------------------------------+ |
|
||||
| | Sprint 001 (Linker) --> Sprint 002 (CLI) |
|
||||
| +--------------------------------+ |
|
||||
| |
|
||||
| BATCH 004: Golden Pairs (Validation) - Can start in parallel with Batch 001 |
|
||||
| +------------------------------------------------------------------+ |
|
||||
| | Sprint 001 (Model) --> Sprint 002 (Pipeline) --> Sprint 003 (Corpus) |
|
||||
| +------------------------------------------------------------------+ |
|
||||
| | |
|
||||
| v |
|
||||
| Uses Batch 001 Sprint 001 (ELF Hashes) for validation |
|
||||
| |
|
||||
+-----------------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
### Determinism Requirements
|
||||
|
||||
All components must follow CLAUDE.md Section 8 determinism rules:
|
||||
|
||||
| Requirement | Implementation |
|
||||
|-------------|----------------|
|
||||
| Timestamps | Inject `TimeProvider`, use UTC ISO-8601 |
|
||||
| IDs | Inject `IGuidGenerator` or derive from content |
|
||||
| Ordering | Sort sections by name, layers by index |
|
||||
| JSON | RFC 8785 canonical encoding for hashing |
|
||||
| Hashes | SHA-256 lowercase hex, no prefix |
|
||||
|
||||
### DSSE/In-Toto Standards
|
||||
|
||||
| Standard | Version | Usage |
|
||||
|----------|---------|-------|
|
||||
| DSSE | v1 | Envelope format for all attestations |
|
||||
| In-Toto | v1.0 | Predicate wrapper (`_type`, `subject`, `predicateType`) |
|
||||
| BinaryDiffV1 | 1.0.0 | Custom predicate for binary diff attestations |
|
||||
| Rekor | v1 | Optional transparency log integration |
|
||||
|
||||
### Test Requirements
|
||||
|
||||
| Category | Coverage |
|
||||
|----------|----------|
|
||||
| Unit | All public APIs, serialization round-trips |
|
||||
| Integration | End-to-end with test containers |
|
||||
| Determinism | Identical inputs produce identical outputs |
|
||||
| Golden | Validation against known-good corpus |
|
||||
|
||||
## File Manifest
|
||||
|
||||
### Sprint Files
|
||||
|
||||
```
|
||||
docs/implplan/
|
||||
+-- SPRINT_20260113_000_MASTER_INDEX_oci_binary_integrity.md # This file
|
||||
|
|
||||
+-- Batch 001: Binary Diff Attestation
|
||||
| +-- SPRINT_20260113_001_000_INDEX_binary_diff_attestation.md
|
||||
| +-- SPRINT_20260113_001_001_SCANNER_elf_section_hashes.md
|
||||
| +-- SPRINT_20260113_001_002_ATTESTOR_binary_diff_predicate.md
|
||||
| +-- SPRINT_20260113_001_003_CLI_binary_diff_command.md
|
||||
| +-- SPRINT_20260113_001_004_DOCS_binary_diff_attestation.md
|
||||
|
|
||||
+-- Batch 002: Image Index Resolution
|
||||
| +-- SPRINT_20260113_002_000_INDEX_image_index_resolution.md
|
||||
| +-- SPRINT_20260113_002_001_SCANNER_image_inspector_service.md
|
||||
| +-- SPRINT_20260113_002_002_CLI_image_inspect_command.md
|
||||
| +-- SPRINT_20260113_002_003_DOCS_image_inspection.md
|
||||
|
|
||||
+-- Batch 003: VEX Evidence Linking
|
||||
| +-- SPRINT_20260113_003_000_INDEX_vex_evidence_linking.md
|
||||
| +-- SPRINT_20260113_003_001_EXCITITOR_vex_evidence_linker.md
|
||||
| +-- SPRINT_20260113_003_002_CLI_vex_evidence_integration.md
|
||||
|
|
||||
+-- Batch 004: Golden Pairs Pilot
|
||||
+-- SPRINT_20260113_004_000_INDEX_golden_pairs_pilot.md
|
||||
+-- SPRINT_20260113_004_001_TOOLS_golden_pairs_data_model.md
|
||||
+-- SPRINT_20260113_004_002_TOOLS_mirror_diff_pipeline.md
|
||||
+-- SPRINT_20260113_004_003_TOOLS_pilot_corpus.md
|
||||
```
|
||||
|
||||
### Schema Files
|
||||
|
||||
```
|
||||
docs/schemas/
|
||||
+-- binarydiff-v1.schema.json # Binary diff attestation (Batch 001)
|
||||
+-- golden-pair-v1.schema.json # Golden pair metadata (Batch 004)
|
||||
+-- golden-pairs-index.schema.json # Corpus index (Batch 004)
|
||||
```
|
||||
|
||||
### Source Directories
|
||||
|
||||
```
|
||||
src/
|
||||
+-- Scanner/
|
||||
| +-- __Libraries/
|
||||
| +-- StellaOps.Scanner.Analyzers.Native/
|
||||
| +-- Sections/ # ELF/PE section hash extraction
|
||||
+-- Attestor/
|
||||
| +-- StellaOps.Attestor.Core/
|
||||
| +-- Predicates/
|
||||
| +-- BinaryDiffV1.cs # Binary diff predicate
|
||||
+-- Excititor/
|
||||
| +-- __Libraries/
|
||||
| +-- StellaOps.Excititor.Core/
|
||||
| +-- Evidence/ # VEX evidence linking
|
||||
+-- Cli/
|
||||
| +-- StellaOps.Cli/
|
||||
| +-- Commands/
|
||||
| +-- BinaryDiffCommandGroup.cs
|
||||
| +-- ImageInspectCommandGroup.cs
|
||||
+-- Tools/
|
||||
+-- GoldenPairs/
|
||||
+-- StellaOps.Tools.GoldenPairs/
|
||||
|
||||
datasets/
|
||||
+-- golden-pairs/
|
||||
+-- index.json
|
||||
+-- README.md
|
||||
+-- CVE-2022-0847/
|
||||
+-- CVE-2021-3156/
|
||||
```
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Functional Metrics
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| ELF section hash accuracy | 100% match with reference implementation |
|
||||
| Binary diff verdict accuracy | >= 95% on golden pairs corpus |
|
||||
| Attestation verification | 100% pass Rekor/in-toto validation |
|
||||
| VEX evidence link coverage | >= 90% of applicable entries |
|
||||
|
||||
### Performance Metrics
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Section hash extraction | < 100ms per binary |
|
||||
| Binary diff comparison | < 500ms per pair |
|
||||
| Image index resolution | < 2s for multi-arch images |
|
||||
|
||||
## Risk Register
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| PE section hashing complexity | Medium | Medium | Defer PrintNightmare if PE not ready |
|
||||
| Large kernel binaries | Medium | Low | Extract specific .ko modules |
|
||||
| Package archive availability | Medium | High | Cache packages locally |
|
||||
| Cross-platform DSSE signing | Low | Medium | Use portable signing libraries |
|
||||
|
||||
## Execution Schedule
|
||||
|
||||
### Recommended Order
|
||||
|
||||
1. **Week 1-2:** Batch 001 Sprints 1-2 (ELF hashes, predicate)
|
||||
2. **Week 2-3:** Batch 002 Sprint 1 (image inspector) + Batch 004 Sprint 1 (data model)
|
||||
3. **Week 3-4:** Batch 001 Sprint 3 (CLI) + Batch 002 Sprint 2 (CLI)
|
||||
4. **Week 4-5:** Batch 003 (VEX linking) + Batch 004 Sprint 2 (pipeline)
|
||||
5. **Week 5-6:** Documentation sprints + Batch 004 Sprint 3 (corpus)
|
||||
|
||||
### Parallelization Opportunities
|
||||
|
||||
- Batch 004 Sprint 1 can start immediately (no dependencies)
|
||||
- Documentation sprints can run in parallel with implementation
|
||||
- Batch 002 Sprint 1 can start after Batch 001 Sprint 1
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Master index created from product advisory analysis. | Project Mgmt |
|
||||
| 2026-01-13 | Batch 001 INDEX already existed; added to master index. | Project Mgmt |
|
||||
| 2026-01-13 | Batches 002, 003, 004 sprint files created. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED 2026-01-13**: Four-batch structure covering full advisory scope.
|
||||
- **APPROVED 2026-01-13**: ELF-first approach; PE support conditional on Batch 001 progress.
|
||||
- **APPROVED 2026-01-13**: Golden pairs stored in datasets/, not git LFS initially.
|
||||
- **APPROVED 2026-01-13**: VEX evidence linking extends existing Excititor module.
|
||||
- **RISK**: PrintNightmare (PE) may be deferred if PE section hashing not ready.
|
||||
- **RISK**: Kernel binaries are large; may need to extract specific modules.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Batch 001 complete -> Core binary diff infrastructure operational
|
||||
- Batch 002 complete -> Multi-arch image inspection available
|
||||
- Batch 003 complete -> VEX entries include evidence links
|
||||
- Batch 004 complete -> Validation corpus ready for CI integration
|
||||
- All batches complete -> Full OCI layer-level integrity verification operational
|
||||
|
||||
## References
|
||||
|
||||
- [OCI Image Index Specification](https://github.com/opencontainers/image-spec/blob/main/image-index.md)
|
||||
- [DSSE Specification](https://github.com/secure-systems-lab/dsse)
|
||||
- [In-Toto Attestation Framework](https://github.com/in-toto/attestation)
|
||||
- [CycloneDX VEX](https://cyclonedx.org/capabilities/vex/)
|
||||
- [ELF Specification](https://refspecs.linuxfoundation.org/elf/elf.pdf)
|
||||
- [PE Format](https://docs.microsoft.com/en-us/windows/win32/debug/pe-format)
|
||||
@@ -0,0 +1,174 @@
|
||||
# Sprint Batch 20260113_001 - Binary Diff Attestation (ELF Section Hashes)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This sprint batch implements **targeted enhancements** for binary-level image integrity verification, focusing on ELF section-level hashing for vendor backport detection and DSSE-signed attestations for binary diffs. This addresses the genuine gaps identified in the OCI Layer-Level Image Integrity advisory analysis while avoiding redundant work on already-implemented capabilities.
|
||||
|
||||
**Scope:** ELF-only (PE/Mach-O deferred to M2+)
|
||||
**Effort Estimate:** 5-7 story points across 4 sprints
|
||||
**Priority:** Medium (enhancement, not blocking)
|
||||
|
||||
## Background
|
||||
|
||||
### Advisory Analysis Summary
|
||||
|
||||
The original product advisory proposed comprehensive OCI layer-level verification capabilities. Analysis revealed:
|
||||
|
||||
| Category | Coverage |
|
||||
|----------|----------|
|
||||
| **Already Implemented** | ~80% (OCI manifest parsing, layer SBOM fragmentation, DSSE pipeline, VEX emission) |
|
||||
| **Partial Overlap** | ~15% (ELF symbols exist, section hashes missing) |
|
||||
| **Genuine Gaps** | ~5% (section hashes, BinaryDiffV1 predicate, CLI diff verb) |
|
||||
|
||||
This batch addresses only the genuine gaps to maximize value while avoiding redundant effort.
|
||||
|
||||
### Existing Capabilities (No Work Needed)
|
||||
|
||||
- OCI manifest/index parsing with Docker & OCI media types
|
||||
- Per-layer SBOM fragmentation with three-way diff
|
||||
- DSSE envelope creation → Attestor → Rekor pipeline
|
||||
- VEX emission with trust scoring and evidence links
|
||||
- ELF Build-ID, symbol table parsing, link graph analysis
|
||||
|
||||
### New Capabilities (This Batch)
|
||||
|
||||
1. **ELF Section Hash Extractor** - SHA-256 per `.text`, `.rodata`, `.data`, `.symtab` sections
|
||||
2. **BinaryDiffV1 In-Toto Predicate** - Schema for binary-level diff attestations
|
||||
3. **CLI `stella scan diff --mode=elf`** - Binary-section-level diff with DSSE output
|
||||
4. **Documentation** - Architecture docs and CLI reference updates
|
||||
|
||||
## Sprint Index
|
||||
|
||||
| Sprint | ID | Module | Topic | Status | Owner |
|
||||
|--------|-----|--------|-------|--------|-------|
|
||||
| 1 | SPRINT_20260113_001_001 | SCANNER | ELF Section Hash Extractor | TODO | Guild - Scanner |
|
||||
| 2 | SPRINT_20260113_001_002 | ATTESTOR | BinaryDiffV1 In-Toto Predicate | TODO | Guild - Attestor |
|
||||
| 3 | SPRINT_20260113_001_003 | CLI | Binary Diff Command Enhancement | TODO | Guild - CLI |
|
||||
| 4 | SPRINT_20260113_001_004 | DOCS | Documentation & Architecture | TODO | Guild - Docs |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Dependency Graph │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Sprint 1 (ELF Section Hashes) │
|
||||
│ │ │
|
||||
│ ├──────────────────┐ │
|
||||
│ ▼ ▼ │
|
||||
│ Sprint 2 (Predicate) Sprint 4 (Docs) │
|
||||
│ │ │ │
|
||||
│ ▼ │ │
|
||||
│ Sprint 3 (CLI) ─────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sprint 1** is foundational (no dependencies)
|
||||
- **Sprint 2** depends on Sprint 1 (uses section hash models)
|
||||
- **Sprint 3** depends on Sprint 1 & 2 (consumes extractor and predicate)
|
||||
- **Sprint 4** can proceed in parallel with Sprints 2-3
|
||||
|
||||
## Acceptance Criteria (Batch-Level)
|
||||
|
||||
### Must Have
|
||||
|
||||
1. **Section Hash Extraction**
|
||||
- Compute SHA-256 for `.text`, `.rodata`, `.data`, `.symtab` ELF sections
|
||||
- Deterministic output (stable ordering, canonical JSON)
|
||||
- Evidence properties in SBOM components
|
||||
|
||||
2. **BinaryDiffV1 Predicate**
|
||||
- In-toto compliant predicate schema
|
||||
- Subjects: image@digest, platform
|
||||
- Inputs: base/target manifests
|
||||
- Findings: per-path section deltas
|
||||
|
||||
3. **CLI Integration**
|
||||
- `stella scan diff --mode=elf` produces binary-section-level diff
|
||||
- `--emit-dsse=<dir>` outputs signed attestations
|
||||
- Human-readable and JSON output formats
|
||||
|
||||
4. **Documentation**
|
||||
- Architecture doc under `docs/modules/scanner/`
|
||||
- CLI reference updates
|
||||
- Predicate schema specification
|
||||
|
||||
### Should Have
|
||||
|
||||
- Confidence scoring for section hash matches (0.0-1.0)
|
||||
- Integration with existing VEX evidence blocks
|
||||
|
||||
### Deferred (Out of Scope)
|
||||
|
||||
- PE/Mach-O section analysis (M2)
|
||||
- Vendor backport corpus and 95% precision target (follow-up sprint)
|
||||
- `ctr images export` integration (use existing OCI blob pull)
|
||||
- Multi-platform diff in single invocation
|
||||
|
||||
## Technical Context
|
||||
|
||||
### Key Files to Extend
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| ELF Analysis | `src/Scanner/StellaOps.Scanner.Analyzers.Native/Hardening/ElfHardeningExtractor.cs` | Add section hash extraction |
|
||||
| Native Models | `src/Scanner/__Libraries/StellaOps.Scanner.Contracts/CallGraphModels.cs` | Section hash models |
|
||||
| DSSE Signing | `src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/WitnessDsseSigner.cs` | Pattern for BinaryDiffSigner |
|
||||
| Predicates | `src/Attestor/__Libraries/StellaOps.Attestor.StandardPredicates/` | Add BinaryDiffV1 |
|
||||
| CLI | `src/Cli/StellaOps.Cli/Commands/` | Add diff subcommand |
|
||||
|
||||
### Determinism Requirements
|
||||
|
||||
Per CLAUDE.md Section 8:
|
||||
|
||||
1. **TimeProvider injection** - No `DateTime.UtcNow` calls
|
||||
2. **Stable ordering** - Section hashes sorted by section name
|
||||
3. **Canonical JSON** - RFC 8785 for digest computation
|
||||
4. **InvariantCulture** - All formatting/parsing
|
||||
5. **DSSE PAE compliance** - Use shared `DsseHelper`
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Section hash instability across compilers | Medium | High | Document compiler/flag assumptions; use position-independent matching as fallback |
|
||||
| ELF parsing edge cases | Low | Medium | Comprehensive test fixtures; existing ELF library handles most cases |
|
||||
| CLI integration conflicts | Low | Low | CLI tests blocked by other agent; coordinate ownership |
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- [ ] All unit tests pass (100% of new code covered)
|
||||
- [ ] Integration tests with synthetic ELF fixtures pass
|
||||
- [ ] CLI help and completions work
|
||||
- [ ] Documentation builds without warnings
|
||||
- [ ] No regressions in existing Scanner tests
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
Before starting implementation, reviewers must read:
|
||||
|
||||
- `docs/README.md`
|
||||
- `docs/ARCHITECTURE_REFERENCE.md`
|
||||
- `docs/modules/scanner/architecture.md` (if exists)
|
||||
- `CLAUDE.md` Section 8 (Code Quality & Determinism Rules)
|
||||
- `src/Scanner/StellaOps.Scanner.Analyzers.Native/AGENTS.md` (if exists)
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint batch created from advisory analysis; 4 sprints defined. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED 2026-01-13**: Scope limited to ELF-only; PE/Mach-O deferred to M2.
|
||||
- **APPROVED 2026-01-13**: 80% precision target for initial release; 95% deferred to corpus sprint.
|
||||
- **RISK**: CLI tests currently blocked by other agent work; Sprint 3 may need coordination.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Sprint 1 completion → Sprint 2 & 4 can start
|
||||
- Sprint 2 completion → Sprint 3 can start
|
||||
- All sprints complete → Integration testing checkpoint
|
||||
@@ -0,0 +1,234 @@
|
||||
# Sprint 20260113_001_001_SCANNER - ELF Section Hash Extractor
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
- Implement per-section SHA-256 hash extraction for ELF binaries
|
||||
- Target sections: `.text`, `.rodata`, `.data`, `.symtab`, `.dynsym`
|
||||
- Integrate with existing `ElfHardeningExtractor` infrastructure
|
||||
- Expose section hashes as SBOM component evidence properties
|
||||
- **Working directory:** `src/Scanner/StellaOps.Scanner.Analyzers.Native/`
|
||||
|
||||
## Dependencies & Concurrency
|
||||
|
||||
- No blocking dependencies (foundational sprint)
|
||||
- Parallel work safe within Scanner.Native module
|
||||
- Sprint 2 (BinaryDiffV1 predicate) depends on this sprint's models
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
- `docs/README.md`
|
||||
- `docs/ARCHITECTURE_REFERENCE.md`
|
||||
- `CLAUDE.md` Section 8 (Determinism Rules)
|
||||
- `src/Scanner/StellaOps.Scanner.Analyzers.Native/AGENTS.md` (if exists)
|
||||
- ELF specification reference (https://refspecs.linuxfoundation.org/elf/elf.pdf)
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
|---|---------|--------|---------------------------|--------|-----------------|
|
||||
| 1 | ELF-SECTION-MODELS-0001 | TODO | None | Guild - Scanner | Define `ElfSectionHash` and `ElfSectionHashSet` models in `src/Scanner/__Libraries/StellaOps.Scanner.Contracts/`. Include section name, offset, size, SHA-256 hash, and optional BLAKE3 hash. |
|
||||
| 2 | ELF-SECTION-EXTRACTOR-0001 | TODO | Depends on ELF-SECTION-MODELS-0001 | Guild - Scanner | Implement `ElfSectionHashExtractor` class that reads ELF sections and computes per-section hashes. Integrate with existing ELF parsing in `ElfHardeningExtractor`. |
|
||||
| 3 | ELF-SECTION-CONFIG-0001 | TODO | Depends on ELF-SECTION-EXTRACTOR-0001 | Guild - Scanner | Add configuration options for section hash extraction: enabled/disabled, section allowlist, hash algorithms. Use `IOptions<T>` with `ValidateOnStart`. |
|
||||
| 4 | ELF-SECTION-EVIDENCE-0001 | TODO | Depends on ELF-SECTION-EXTRACTOR-0001 | Guild - Scanner | Emit section hashes as SBOM component `properties[]` with keys: `evidence:section:<name>:sha256`, `evidence:section:<name>:blake3`, `evidence:section:<name>:size`. |
|
||||
| 5 | ELF-SECTION-DI-0001 | TODO | Depends on all above | Guild - Scanner | Register `ElfSectionHashExtractor` in `ServiceCollectionExtensions.cs`. Ensure `TimeProvider` and `IGuidGenerator` are injected for determinism. |
|
||||
| 6 | ELF-SECTION-TESTS-0001 | TODO | Depends on all above | Guild - Scanner | Add unit tests in `src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/` covering: valid ELF with all sections, stripped ELF (missing symtab), malformed ELF, empty sections, large binaries. |
|
||||
| 7 | ELF-SECTION-FIXTURES-0001 | TODO | Depends on ELF-SECTION-TESTS-0001 | Guild - Scanner | Create synthetic ELF test fixtures under `src/Scanner/__Tests/__Datasets/elf-section-hashes/` with known section contents for golden hash verification. |
|
||||
| 8 | ELF-SECTION-DETERMINISM-0001 | TODO | Depends on all above | Guild - Scanner | Add determinism regression test: same ELF input produces identical section hashes across runs. Use `FakeTimeProvider` and fixed GUID generator. |
|
||||
|
||||
## Technical Specification
|
||||
|
||||
### ElfSectionHash Model
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Scanner.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a cryptographic hash of an ELF section.
|
||||
/// </summary>
|
||||
public sealed record ElfSectionHash
|
||||
{
|
||||
/// <summary>Section name (e.g., ".text", ".rodata").</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Section offset in file.</summary>
|
||||
public required long Offset { get; init; }
|
||||
|
||||
/// <summary>Section size in bytes.</summary>
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of section contents (lowercase hex).</summary>
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
/// <summary>Optional BLAKE3-256 hash of section contents (lowercase hex).</summary>
|
||||
public string? Blake3 { get; init; }
|
||||
|
||||
/// <summary>Section type from ELF header.</summary>
|
||||
public required ElfSectionType SectionType { get; init; }
|
||||
|
||||
/// <summary>Section flags from ELF header.</summary>
|
||||
public required ElfSectionFlags Flags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection of section hashes for a single ELF binary.
|
||||
/// </summary>
|
||||
public sealed record ElfSectionHashSet
|
||||
{
|
||||
/// <summary>Path to the ELF binary.</summary>
|
||||
public required string FilePath { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of the entire file.</summary>
|
||||
public required string FileHash { get; init; }
|
||||
|
||||
/// <summary>Build-ID from .note.gnu.build-id if present.</summary>
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>Section hashes, sorted by section name.</summary>
|
||||
public required ImmutableArray<ElfSectionHash> Sections { get; init; }
|
||||
|
||||
/// <summary>Extraction timestamp (UTC ISO-8601).</summary>
|
||||
public required DateTimeOffset ExtractedAt { get; init; }
|
||||
|
||||
/// <summary>Extractor version for reproducibility.</summary>
|
||||
public required string ExtractorVersion { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### Extractor Interface
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Scanner.Analyzers.Native;
|
||||
|
||||
public interface IElfSectionHashExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts section hashes from an ELF binary.
|
||||
/// </summary>
|
||||
/// <param name="elfPath">Path to the ELF file.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Section hash set, or null if not a valid ELF.</returns>
|
||||
Task<ElfSectionHashSet?> ExtractAsync(
|
||||
string elfPath,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts section hashes from ELF bytes in memory.
|
||||
/// </summary>
|
||||
Task<ElfSectionHashSet?> ExtractFromBytesAsync(
|
||||
ReadOnlyMemory<byte> elfBytes,
|
||||
string virtualPath,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
### Target Sections
|
||||
|
||||
| Section | Purpose | Backport Relevance |
|
||||
|---------|---------|-------------------|
|
||||
| `.text` | Executable code | **High** - patched functions change this |
|
||||
| `.rodata` | Read-only data | Medium - string constants may change |
|
||||
| `.data` | Initialized data | Low - rarely changes for patches |
|
||||
| `.symtab` | Symbol table | **High** - function signatures |
|
||||
| `.dynsym` | Dynamic symbols | **High** - exported API |
|
||||
| `.gnu.hash` | GNU hash table | Low - derived from symbols |
|
||||
|
||||
### SBOM Evidence Properties
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "library",
|
||||
"name": "libssl.so.3",
|
||||
"properties": [
|
||||
{"name": "evidence:build-id", "value": "abc123..."},
|
||||
{"name": "evidence:section:.text:sha256", "value": "e3b0c442..."},
|
||||
{"name": "evidence:section:.text:size", "value": "1048576"},
|
||||
{"name": "evidence:section:.rodata:sha256", "value": "d7a8fbb3..."},
|
||||
{"name": "evidence:section:.symtab:sha256", "value": "9f86d081..."},
|
||||
{"name": "evidence:section-set:sha256", "value": "combined_hash..."},
|
||||
{"name": "evidence:extractor-version", "value": "1.0.0"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Determinism Requirements
|
||||
|
||||
1. **Ordering**: Sections sorted lexicographically by name
|
||||
2. **Hash format**: Lowercase hexadecimal, no prefix
|
||||
3. **Timestamps**: From injected `TimeProvider.GetUtcNow()`
|
||||
4. **Version string**: Assembly version or build metadata
|
||||
5. **JSON serialization**: RFC 8785 canonical for any digest computation
|
||||
|
||||
### Configuration Schema
|
||||
|
||||
```yaml
|
||||
scanner:
|
||||
native:
|
||||
sectionHashes:
|
||||
enabled: true
|
||||
algorithms:
|
||||
- sha256
|
||||
- blake3 # optional
|
||||
sections:
|
||||
- .text
|
||||
- .rodata
|
||||
- .data
|
||||
- .symtab
|
||||
- .dynsym
|
||||
maxSectionSize: 104857600 # 100MB limit per section
|
||||
```
|
||||
|
||||
## Test Cases
|
||||
|
||||
### Unit Tests
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `ExtractAsync_ValidElf_ReturnsAllSections` | Standard ELF with all target sections | All 5 sections extracted with valid hashes |
|
||||
| `ExtractAsync_StrippedElf_OmitsSymtab` | Stripped binary without .symtab | Only .text, .rodata, .data returned |
|
||||
| `ExtractAsync_InvalidElf_ReturnsNull` | Non-ELF file (PE, Mach-O, random) | Returns null, no exception |
|
||||
| `ExtractAsync_EmptySection_ReturnsEmptyHash` | ELF with zero-size .data | Hash of empty content (`e3b0c442...`) |
|
||||
| `ExtractAsync_LargeSection_RespectsLimit` | Section > maxSectionSize | Section skipped or truncated per config |
|
||||
| `ExtractAsync_Deterministic_SameOutput` | Same ELF, multiple runs | Identical `ElfSectionHashSet` |
|
||||
| `ExtractFromBytesAsync_SameAsFile` | Memory vs file extraction | Identical results |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `LayerAnalysis_ElfWithSections_EmitsEvidence` | Container layer with ELF binaries | SBOM components have section hash properties |
|
||||
| `Diff_SameBinaryDifferentPatch_DetectsSectionChange` | Two builds with backport | `.text` hash differs, other sections same |
|
||||
|
||||
### Fixtures
|
||||
|
||||
Create under `src/Scanner/__Tests/__Datasets/elf-section-hashes/`:
|
||||
|
||||
```
|
||||
elf-section-hashes/
|
||||
├── README.md # Fixture documentation
|
||||
├── standard-amd64.elf # Standard ELF with all sections
|
||||
├── standard-amd64.golden.json # Expected section hashes
|
||||
├── stripped-amd64.elf # Stripped binary
|
||||
├── stripped-amd64.golden.json
|
||||
├── minimal-arm64.elf # Minimal ELF (few sections)
|
||||
├── minimal-arm64.golden.json
|
||||
└── corrupt.bin # Invalid ELF magic
|
||||
```
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint created from advisory analysis. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED**: SHA-256 as primary hash; BLAKE3 optional for performance.
|
||||
- **APPROVED**: 100MB per-section limit to prevent memory exhaustion.
|
||||
- **RISK**: Some ELF parsers may handle edge cases differently; use LibObjectFile or similar well-tested library.
|
||||
- **RISK**: Section ordering may vary by toolchain; normalize by sorting.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Task 1-2 complete → Models and extractor ready for integration
|
||||
- Task 6-8 complete → Sprint can be marked DONE
|
||||
- Unblock Sprint 2 (BinaryDiffV1 predicate)
|
||||
@@ -0,0 +1,441 @@
|
||||
# Sprint 20260113_001_002_ATTESTOR - BinaryDiffV1 In-Toto Predicate
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
- Define `BinaryDiffV1` in-toto predicate schema for binary-level diff attestations
|
||||
- Implement predicate builder and serializer
|
||||
- Integrate with existing DSSE signing infrastructure
|
||||
- Support both ELF section diffs and future PE/Mach-O extensions
|
||||
- **Working directory:** `src/Attestor/__Libraries/StellaOps.Attestor.StandardPredicates/`
|
||||
|
||||
## Dependencies & Concurrency
|
||||
|
||||
- **Depends on:** Sprint 001 (ELF Section Hash models)
|
||||
- Parallel work safe within Attestor module
|
||||
- Sprint 3 (CLI) depends on this sprint
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
- `docs/README.md`
|
||||
- `docs/ARCHITECTURE_REFERENCE.md`
|
||||
- `CLAUDE.md` Section 8 (Determinism Rules)
|
||||
- in-toto attestation specification (https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md)
|
||||
- DSSE envelope specification (https://github.com/secure-systems-lab/dsse/blob/master/envelope.md)
|
||||
- Existing predicates: `src/Attestor/__Libraries/StellaOps.Attestor.StandardPredicates/`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
|---|---------|--------|---------------------------|--------|-----------------|
|
||||
| 1 | BINARYDIFF-SCHEMA-0001 | TODO | Sprint 001 models | Guild - Attestor | Define `BinaryDiffV1` predicate schema with JSON Schema and C# models. Include subjects, inputs, findings, and verification materials. |
|
||||
| 2 | BINARYDIFF-MODELS-0001 | TODO | Depends on BINARYDIFF-SCHEMA-0001 | Guild - Attestor | Implement C# record types for `BinaryDiffPredicate`, `BinaryDiffSubject`, `BinaryDiffInput`, `BinaryDiffFinding`, `SectionDelta`. |
|
||||
| 3 | BINARYDIFF-BUILDER-0001 | TODO | Depends on BINARYDIFF-MODELS-0001 | Guild - Attestor | Implement `BinaryDiffPredicateBuilder` with fluent API for constructing predicates from section hash comparisons. |
|
||||
| 4 | BINARYDIFF-SERIALIZER-0001 | TODO | Depends on BINARYDIFF-MODELS-0001 | Guild - Attestor | Implement canonical JSON serialization using RFC 8785. Register with existing `IPredicateSerializer` infrastructure. |
|
||||
| 5 | BINARYDIFF-SIGNER-0001 | TODO | Depends on all above | Guild - Attestor | Implement `BinaryDiffDsseSigner` following `WitnessDsseSigner` pattern. Payload type: `stellaops.binarydiff.v1`. |
|
||||
| 6 | BINARYDIFF-VERIFIER-0001 | TODO | Depends on BINARYDIFF-SIGNER-0001 | Guild - Attestor | Implement `BinaryDiffDsseVerifier` for signature and schema validation. |
|
||||
| 7 | BINARYDIFF-DI-0001 | TODO | Depends on all above | Guild - Attestor | Register all services in DI. Add `IOptions<BinaryDiffOptions>` for configuration. |
|
||||
| 8 | BINARYDIFF-TESTS-0001 | TODO | Depends on all above | Guild - Attestor | Add comprehensive unit tests covering: schema validation, serialization round-trip, signing/verification, edge cases (empty findings, large diffs). |
|
||||
| 9 | BINARYDIFF-JSONSCHEMA-0001 | TODO | Depends on BINARYDIFF-SCHEMA-0001 | Guild - Attestor | Publish JSON Schema to `docs/schemas/binarydiff-v1.schema.json` for external validation. |
|
||||
|
||||
## Technical Specification
|
||||
|
||||
### Predicate Type
|
||||
|
||||
```
|
||||
stellaops.binarydiff.v1
|
||||
```
|
||||
|
||||
### BinaryDiffV1 Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://stellaops.io/schemas/binarydiff-v1.schema.json",
|
||||
"title": "BinaryDiffV1",
|
||||
"description": "In-toto predicate for binary-level diff attestations",
|
||||
"type": "object",
|
||||
"required": ["predicateType", "subjects", "inputs", "findings", "metadata"],
|
||||
"properties": {
|
||||
"predicateType": {
|
||||
"const": "stellaops.binarydiff.v1"
|
||||
},
|
||||
"subjects": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/BinaryDiffSubject" },
|
||||
"minItems": 1
|
||||
},
|
||||
"inputs": {
|
||||
"$ref": "#/$defs/BinaryDiffInputs"
|
||||
},
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/BinaryDiffFinding" }
|
||||
},
|
||||
"metadata": {
|
||||
"$ref": "#/$defs/BinaryDiffMetadata"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"BinaryDiffSubject": {
|
||||
"type": "object",
|
||||
"required": ["name", "digest"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Image reference (e.g., docker://repo/app@sha256:...)"
|
||||
},
|
||||
"digest": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"platform": {
|
||||
"$ref": "#/$defs/Platform"
|
||||
}
|
||||
}
|
||||
},
|
||||
"BinaryDiffInputs": {
|
||||
"type": "object",
|
||||
"required": ["base", "target"],
|
||||
"properties": {
|
||||
"base": { "$ref": "#/$defs/ImageReference" },
|
||||
"target": { "$ref": "#/$defs/ImageReference" }
|
||||
}
|
||||
},
|
||||
"ImageReference": {
|
||||
"type": "object",
|
||||
"required": ["digest"],
|
||||
"properties": {
|
||||
"reference": { "type": "string" },
|
||||
"digest": { "type": "string" },
|
||||
"manifestDigest": { "type": "string" },
|
||||
"platform": { "$ref": "#/$defs/Platform" }
|
||||
}
|
||||
},
|
||||
"Platform": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"os": { "type": "string" },
|
||||
"architecture": { "type": "string" },
|
||||
"variant": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"BinaryDiffFinding": {
|
||||
"type": "object",
|
||||
"required": ["path", "changeType", "binaryFormat"],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path within the image filesystem"
|
||||
},
|
||||
"changeType": {
|
||||
"enum": ["added", "removed", "modified", "unchanged"]
|
||||
},
|
||||
"binaryFormat": {
|
||||
"enum": ["elf", "pe", "macho", "unknown"]
|
||||
},
|
||||
"layerDigest": {
|
||||
"type": "string",
|
||||
"description": "Layer that introduced this change"
|
||||
},
|
||||
"baseHashes": {
|
||||
"$ref": "#/$defs/SectionHashSet"
|
||||
},
|
||||
"targetHashes": {
|
||||
"$ref": "#/$defs/SectionHashSet"
|
||||
},
|
||||
"sectionDeltas": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/SectionDelta" }
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"verdict": {
|
||||
"enum": ["patched", "vanilla", "unknown", "incompatible"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"SectionHashSet": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"buildId": { "type": "string" },
|
||||
"fileHash": { "type": "string" },
|
||||
"sections": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/SectionInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SectionInfo": {
|
||||
"type": "object",
|
||||
"required": ["sha256", "size"],
|
||||
"properties": {
|
||||
"sha256": { "type": "string" },
|
||||
"blake3": { "type": "string" },
|
||||
"size": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"SectionDelta": {
|
||||
"type": "object",
|
||||
"required": ["section", "status"],
|
||||
"properties": {
|
||||
"section": {
|
||||
"type": "string",
|
||||
"description": "Section name (e.g., .text, .rodata)"
|
||||
},
|
||||
"status": {
|
||||
"enum": ["identical", "modified", "added", "removed"]
|
||||
},
|
||||
"baseSha256": { "type": "string" },
|
||||
"targetSha256": { "type": "string" },
|
||||
"sizeDelta": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"BinaryDiffMetadata": {
|
||||
"type": "object",
|
||||
"required": ["toolVersion", "analysisTimestamp"],
|
||||
"properties": {
|
||||
"toolVersion": { "type": "string" },
|
||||
"analysisTimestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"configDigest": { "type": "string" },
|
||||
"totalBinaries": { "type": "integer" },
|
||||
"modifiedBinaries": { "type": "integer" },
|
||||
"analyzedSections": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### C# Model Classes
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Attestor.StandardPredicates.BinaryDiff;
|
||||
|
||||
/// <summary>
|
||||
/// BinaryDiffV1 predicate for in-toto attestations.
|
||||
/// </summary>
|
||||
public sealed record BinaryDiffPredicate
|
||||
{
|
||||
public const string PredicateType = "stellaops.binarydiff.v1";
|
||||
|
||||
public required ImmutableArray<BinaryDiffSubject> Subjects { get; init; }
|
||||
public required BinaryDiffInputs Inputs { get; init; }
|
||||
public required ImmutableArray<BinaryDiffFinding> Findings { get; init; }
|
||||
public required BinaryDiffMetadata Metadata { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BinaryDiffSubject
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required ImmutableDictionary<string, string> Digest { get; init; }
|
||||
public Platform? Platform { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BinaryDiffInputs
|
||||
{
|
||||
public required ImageReference Base { get; init; }
|
||||
public required ImageReference Target { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ImageReference
|
||||
{
|
||||
public string? Reference { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public string? ManifestDigest { get; init; }
|
||||
public Platform? Platform { get; init; }
|
||||
}
|
||||
|
||||
public sealed record Platform
|
||||
{
|
||||
public required string Os { get; init; }
|
||||
public required string Architecture { get; init; }
|
||||
public string? Variant { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BinaryDiffFinding
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public required ChangeType ChangeType { get; init; }
|
||||
public required BinaryFormat BinaryFormat { get; init; }
|
||||
public string? LayerDigest { get; init; }
|
||||
public SectionHashSet? BaseHashes { get; init; }
|
||||
public SectionHashSet? TargetHashes { get; init; }
|
||||
public ImmutableArray<SectionDelta> SectionDeltas { get; init; }
|
||||
public double? Confidence { get; init; }
|
||||
public Verdict? Verdict { get; init; }
|
||||
}
|
||||
|
||||
public enum ChangeType { Added, Removed, Modified, Unchanged }
|
||||
public enum BinaryFormat { Elf, Pe, Macho, Unknown }
|
||||
public enum Verdict { Patched, Vanilla, Unknown, Incompatible }
|
||||
|
||||
public sealed record SectionHashSet
|
||||
{
|
||||
public string? BuildId { get; init; }
|
||||
public required string FileHash { get; init; }
|
||||
public required ImmutableDictionary<string, SectionInfo> Sections { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SectionInfo
|
||||
{
|
||||
public required string Sha256 { get; init; }
|
||||
public string? Blake3 { get; init; }
|
||||
public required long Size { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SectionDelta
|
||||
{
|
||||
public required string Section { get; init; }
|
||||
public required SectionStatus Status { get; init; }
|
||||
public string? BaseSha256 { get; init; }
|
||||
public string? TargetSha256 { get; init; }
|
||||
public long? SizeDelta { get; init; }
|
||||
}
|
||||
|
||||
public enum SectionStatus { Identical, Modified, Added, Removed }
|
||||
|
||||
public sealed record BinaryDiffMetadata
|
||||
{
|
||||
public required string ToolVersion { get; init; }
|
||||
public required DateTimeOffset AnalysisTimestamp { get; init; }
|
||||
public string? ConfigDigest { get; init; }
|
||||
public int TotalBinaries { get; init; }
|
||||
public int ModifiedBinaries { get; init; }
|
||||
public ImmutableArray<string> AnalyzedSections { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### Builder API
|
||||
|
||||
```csharp
|
||||
public interface IBinaryDiffPredicateBuilder
|
||||
{
|
||||
IBinaryDiffPredicateBuilder WithSubject(string name, string digest, Platform? platform = null);
|
||||
IBinaryDiffPredicateBuilder WithInputs(ImageReference baseImage, ImageReference targetImage);
|
||||
IBinaryDiffPredicateBuilder AddFinding(BinaryDiffFinding finding);
|
||||
IBinaryDiffPredicateBuilder WithMetadata(Action<BinaryDiffMetadataBuilder> configure);
|
||||
BinaryDiffPredicate Build();
|
||||
}
|
||||
```
|
||||
|
||||
### DSSE Integration
|
||||
|
||||
```csharp
|
||||
public interface IBinaryDiffDsseSigner
|
||||
{
|
||||
Task<BinaryDiffDsseResult> SignAsync(
|
||||
BinaryDiffPredicate predicate,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record BinaryDiffDsseResult
|
||||
{
|
||||
public required string PayloadType { get; init; } // stellaops.binarydiff.v1
|
||||
public required byte[] Payload { get; init; }
|
||||
public required ImmutableArray<DsseSignature> Signatures { get; init; }
|
||||
public required string EnvelopeJson { get; init; }
|
||||
public string? RekorLogIndex { get; init; }
|
||||
public string? RekorEntryId { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### In-Toto Statement Wrapper
|
||||
|
||||
```json
|
||||
{
|
||||
"_type": "https://in-toto.io/Statement/v1",
|
||||
"subject": [
|
||||
{
|
||||
"name": "docker://registry.example.com/app@sha256:abc123...",
|
||||
"digest": {
|
||||
"sha256": "abc123..."
|
||||
}
|
||||
}
|
||||
],
|
||||
"predicateType": "stellaops.binarydiff.v1",
|
||||
"predicate": {
|
||||
"inputs": {
|
||||
"base": { "digest": "sha256:old..." },
|
||||
"target": { "digest": "sha256:new..." }
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"path": "/usr/lib/libssl.so.3",
|
||||
"changeType": "modified",
|
||||
"binaryFormat": "elf",
|
||||
"sectionDeltas": [
|
||||
{ "section": ".text", "status": "modified", "baseSha256": "...", "targetSha256": "..." }
|
||||
],
|
||||
"confidence": 0.95,
|
||||
"verdict": "patched"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"toolVersion": "1.0.0",
|
||||
"analysisTimestamp": "2026-01-13T12:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Determinism Requirements
|
||||
|
||||
1. **Canonical JSON**: RFC 8785 for all serialization before signing
|
||||
2. **Stable ordering**: Findings sorted by path; sections sorted by name
|
||||
3. **Timestamps**: From injected `TimeProvider`
|
||||
4. **Hash computation**: Use shared `CanonicalJsonSerializer`
|
||||
5. **DSSE PAE**: Use shared `DsseHelper.ComputePreAuthenticationEncoding`
|
||||
|
||||
## Test Cases
|
||||
|
||||
### Unit Tests
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `Serialize_RoundTrip_Identical` | Serialize then deserialize | Identical predicate |
|
||||
| `Serialize_Canonical_DeterministicOutput` | Same predicate, multiple serializations | Byte-identical JSON |
|
||||
| `Build_ValidInputs_CreatesPredicate` | Builder with all required fields | Valid predicate |
|
||||
| `Build_MissingSubject_Throws` | Builder without subject | `ArgumentException` |
|
||||
| `Sign_ValidPredicate_ReturnsEnvelope` | Sign with test key | Valid DSSE envelope |
|
||||
| `Verify_ValidEnvelope_Succeeds` | Verify signed envelope | Verification passes |
|
||||
| `Verify_TamperedPayload_Fails` | Modified payload | Verification fails |
|
||||
| `Schema_ValidJson_Passes` | Valid JSON against schema | Schema validation passes |
|
||||
| `Schema_InvalidJson_Fails` | Missing required field | Schema validation fails |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `SignAndSubmit_RekorIntegration` | Sign and submit to Rekor (test instance) | Log entry created |
|
||||
| `EndToEnd_DiffToAttestation` | From image diff to signed attestation | Valid DSSE with findings |
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint created from advisory analysis. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED**: Predicate type `stellaops.binarydiff.v1` follows StellaOps naming convention.
|
||||
- **APPROVED**: Support both ELF and future PE/Mach-O via `binaryFormat` discriminator.
|
||||
- **RISK**: Schema evolution requires versioning strategy; defer to v2 if breaking changes needed.
|
||||
- **RISK**: Large diffs may produce large attestations; consider summary mode for >1000 findings.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Task 1-4 complete → Schema and models ready for integration
|
||||
- Task 5-6 complete → Signing/verification operational
|
||||
- Task 8 complete → Sprint can be marked DONE
|
||||
- Unblock Sprint 3 (CLI)
|
||||
358
docs/implplan/SPRINT_20260113_001_003_CLI_binary_diff_command.md
Normal file
358
docs/implplan/SPRINT_20260113_001_003_CLI_binary_diff_command.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# Sprint 20260113_001_003_CLI - Binary Diff Command Enhancement
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
- Implement `stella scan diff --mode=elf` for binary-section-level diff
|
||||
- Add `--emit-dsse=<dir>` option for DSSE attestation output
|
||||
- Support human-readable table and JSON output formats
|
||||
- Integrate with existing scan infrastructure and OCI registry client
|
||||
- **Working directory:** `src/Cli/StellaOps.Cli/Commands/`
|
||||
|
||||
## Dependencies & Concurrency
|
||||
|
||||
- **Depends on:** Sprint 001 (ELF Section Hash Extractor)
|
||||
- **Depends on:** Sprint 002 (BinaryDiffV1 Predicate)
|
||||
- **BLOCKED RISK:** CLI tests under active modification; coordinate before touching test files
|
||||
- Parallel work safe for command implementation; test coordination required
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
- `docs/README.md`
|
||||
- `docs/ARCHITECTURE_REFERENCE.md`
|
||||
- `CLAUDE.md` Section 8 (Determinism Rules)
|
||||
- `src/Cli/StellaOps.Cli/AGENTS.md` (if exists)
|
||||
- Existing CLI commands: `src/Cli/StellaOps.Cli/Commands/`
|
||||
- System.CommandLine documentation
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
|---|---------|--------|---------------------------|--------|-----------------|
|
||||
| 1 | CLI-DIFF-COMMAND-0001 | TODO | Sprint 001 & 002 complete | Guild - CLI | Create `BinaryDiffCommand` class under `Commands/Scan/` implementing `stella scan diff` subcommand with required options. |
|
||||
| 2 | CLI-DIFF-OPTIONS-0001 | TODO | Depends on CLI-DIFF-COMMAND-0001 | Guild - CLI | Define command options: `--base` (base image ref), `--target` (target image ref), `--mode` (elf/pe/auto), `--emit-dsse` (output dir), `--format` (table/json), `--platform` (os/arch). |
|
||||
| 3 | CLI-DIFF-SERVICE-0001 | TODO | Depends on CLI-DIFF-OPTIONS-0001 | Guild - CLI | Implement `BinaryDiffService` that orchestrates: image pull, layer extraction, section hash computation, diff computation, predicate building. |
|
||||
| 4 | CLI-DIFF-RENDERER-0001 | TODO | Depends on CLI-DIFF-SERVICE-0001 | Guild - CLI | Implement `BinaryDiffRenderer` for table and JSON output formats. Table shows path, change type, verdict, confidence. JSON outputs full diff structure. |
|
||||
| 5 | CLI-DIFF-DSSE-OUTPUT-0001 | TODO | Depends on CLI-DIFF-SERVICE-0001 | Guild - CLI | Implement DSSE output: one envelope per platform manifest, written to `--emit-dsse` directory with naming convention `{platform}-binarydiff.dsse.json`. |
|
||||
| 6 | CLI-DIFF-PROGRESS-0001 | TODO | Depends on CLI-DIFF-SERVICE-0001 | Guild - CLI | Add progress reporting for long-running operations: layer download progress, binary analysis progress, section hash computation. |
|
||||
| 7 | CLI-DIFF-DI-0001 | TODO | Depends on all above | Guild - CLI | Register all services in `Program.cs` DI setup. Wire up `IHttpClientFactory`, `IElfSectionHashExtractor`, `IBinaryDiffDsseSigner`. |
|
||||
| 8 | CLI-DIFF-HELP-0001 | TODO | Depends on CLI-DIFF-COMMAND-0001 | Guild - CLI | Add comprehensive help text, examples, and shell completions for the new command. |
|
||||
| 9 | CLI-DIFF-TESTS-0001 | BLOCKED | Depends on all above; CLI tests under active modification | Guild - CLI | Add unit tests for command parsing, service logic, and output rendering. Coordinate with other agent before modifying test files. |
|
||||
| 10 | CLI-DIFF-INTEGRATION-0001 | TODO | Depends on CLI-DIFF-TESTS-0001 | Guild - CLI | Add integration test with synthetic OCI images containing known ELF binaries. Verify end-to-end flow. |
|
||||
|
||||
## Technical Specification
|
||||
|
||||
### Command Syntax
|
||||
|
||||
```bash
|
||||
# Basic usage
|
||||
stella scan diff --base <image-ref> --target <image-ref>
|
||||
|
||||
# With binary mode
|
||||
stella scan diff --base docker://repo/app:1.0.0 --target docker://repo/app:1.0.1 --mode=elf
|
||||
|
||||
# With DSSE output
|
||||
stella scan diff --base @sha256:abc... --target @sha256:def... \
|
||||
--mode=elf --emit-dsse=./attestations/
|
||||
|
||||
# JSON output
|
||||
stella scan diff --base image1 --target image2 --format=json > diff.json
|
||||
|
||||
# Specific platform
|
||||
stella scan diff --base image1 --target image2 --platform=linux/amd64
|
||||
```
|
||||
|
||||
### Command Options
|
||||
|
||||
| Option | Short | Type | Required | Default | Description |
|
||||
|--------|-------|------|----------|---------|-------------|
|
||||
| `--base` | `-b` | string | Yes | - | Base image reference (tag or @digest) |
|
||||
| `--target` | `-t` | string | Yes | - | Target image reference (tag or @digest) |
|
||||
| `--mode` | `-m` | enum | No | `auto` | Analysis mode: `elf`, `pe`, `auto` |
|
||||
| `--emit-dsse` | `-d` | path | No | - | Directory for DSSE attestation output |
|
||||
| `--format` | `-f` | enum | No | `table` | Output format: `table`, `json`, `summary` |
|
||||
| `--platform` | `-p` | string | No | - | Platform filter (e.g., `linux/amd64`) |
|
||||
| `--include-unchanged` | - | bool | No | `false` | Include unchanged binaries in output |
|
||||
| `--sections` | - | string[] | No | all | Sections to analyze (e.g., `.text,.rodata`) |
|
||||
| `--registry-auth` | - | string | No | - | Path to Docker config for authentication |
|
||||
| `--timeout` | - | int | No | `300` | Timeout in seconds for operations |
|
||||
| `--verbose` | `-v` | bool | No | `false` | Enable verbose output |
|
||||
|
||||
### Output Formats
|
||||
|
||||
#### Table Format (Default)
|
||||
|
||||
```
|
||||
Binary Diff: docker://repo/app:1.0.0 → docker://repo/app:1.0.1
|
||||
Platform: linux/amd64
|
||||
Analysis Mode: ELF Section Hashes
|
||||
|
||||
PATH CHANGE VERDICT CONFIDENCE SECTIONS CHANGED
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
/usr/lib/libssl.so.3 modified patched 0.95 .text, .rodata
|
||||
/usr/lib/libcrypto.so.3 modified patched 0.92 .text
|
||||
/usr/bin/openssl modified unknown 0.75 .text, .data
|
||||
/usr/lib/libc.so.6 unchanged - - -
|
||||
|
||||
Summary: 4 binaries analyzed, 3 modified, 1 unchanged
|
||||
Patched: 2, Unknown: 1
|
||||
```
|
||||
|
||||
#### JSON Format
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"base": {
|
||||
"reference": "docker://repo/app:1.0.0",
|
||||
"digest": "sha256:abc123..."
|
||||
},
|
||||
"target": {
|
||||
"reference": "docker://repo/app:1.0.1",
|
||||
"digest": "sha256:def456..."
|
||||
},
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"architecture": "amd64"
|
||||
},
|
||||
"analysisMode": "elf",
|
||||
"timestamp": "2026-01-13T12:00:00Z",
|
||||
"findings": [
|
||||
{
|
||||
"path": "/usr/lib/libssl.so.3",
|
||||
"changeType": "modified",
|
||||
"verdict": "patched",
|
||||
"confidence": 0.95,
|
||||
"sectionDeltas": [
|
||||
{ "section": ".text", "status": "modified" },
|
||||
{ "section": ".rodata", "status": "modified" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totalBinaries": 4,
|
||||
"modified": 3,
|
||||
"unchanged": 1,
|
||||
"verdicts": {
|
||||
"patched": 2,
|
||||
"unknown": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Summary Format
|
||||
|
||||
```
|
||||
Binary Diff Summary
|
||||
───────────────────
|
||||
Base: docker://repo/app:1.0.0 (sha256:abc123...)
|
||||
Target: docker://repo/app:1.0.1 (sha256:def456...)
|
||||
Platform: linux/amd64
|
||||
|
||||
Binaries: 4 total, 3 modified, 1 unchanged
|
||||
Verdicts: 2 patched, 1 unknown
|
||||
|
||||
DSSE Attestation: ./attestations/linux-amd64-binarydiff.dsse.json
|
||||
```
|
||||
|
||||
### DSSE Output Structure
|
||||
|
||||
```
|
||||
attestations/
|
||||
├── linux-amd64-binarydiff.dsse.json # DSSE envelope
|
||||
├── linux-amd64-binarydiff.payload.json # Raw predicate (for inspection)
|
||||
└── linux-arm64-binarydiff.dsse.json # (if multi-arch)
|
||||
```
|
||||
|
||||
### Service Architecture
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
public interface IBinaryDiffService
|
||||
{
|
||||
Task<BinaryDiffResult> ComputeDiffAsync(
|
||||
BinaryDiffRequest request,
|
||||
IProgress<BinaryDiffProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record BinaryDiffRequest
|
||||
{
|
||||
public required string BaseImageRef { get; init; }
|
||||
public required string TargetImageRef { get; init; }
|
||||
public required BinaryDiffMode Mode { get; init; }
|
||||
public Platform? Platform { get; init; }
|
||||
public ImmutableArray<string>? Sections { get; init; }
|
||||
public bool IncludeUnchanged { get; init; }
|
||||
public string? RegistryAuthPath { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BinaryDiffResult
|
||||
{
|
||||
public required ImageReference Base { get; init; }
|
||||
public required ImageReference Target { get; init; }
|
||||
public required Platform Platform { get; init; }
|
||||
public required ImmutableArray<BinaryDiffFinding> Findings { get; init; }
|
||||
public required BinaryDiffSummary Summary { get; init; }
|
||||
public BinaryDiffPredicate? Predicate { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BinaryDiffProgress
|
||||
{
|
||||
public required string Phase { get; init; } // "pulling", "extracting", "analyzing", "diffing"
|
||||
public required string CurrentItem { get; init; }
|
||||
public required int Current { get; init; }
|
||||
public required int Total { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### Command Implementation Pattern
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Cli.Commands.Scan;
|
||||
|
||||
public class BinaryDiffCommand : Command
|
||||
{
|
||||
public BinaryDiffCommand() : base("diff", "Compare binaries between two images")
|
||||
{
|
||||
AddOption(BaseOption);
|
||||
AddOption(TargetOption);
|
||||
AddOption(ModeOption);
|
||||
AddOption(EmitDsseOption);
|
||||
AddOption(FormatOption);
|
||||
AddOption(PlatformOption);
|
||||
// ... other options
|
||||
}
|
||||
|
||||
public static Option<string> BaseOption { get; } = new(
|
||||
aliases: ["--base", "-b"],
|
||||
description: "Base image reference (tag or @digest)")
|
||||
{
|
||||
IsRequired = true
|
||||
};
|
||||
|
||||
// ... other options
|
||||
|
||||
public new class Handler : ICommandHandler
|
||||
{
|
||||
private readonly IBinaryDiffService _diffService;
|
||||
private readonly IBinaryDiffDsseSigner _signer;
|
||||
private readonly IBinaryDiffRenderer _renderer;
|
||||
private readonly IConsole _console;
|
||||
|
||||
public async Task<int> InvokeAsync(InvocationContext context)
|
||||
{
|
||||
var cancellationToken = context.GetCancellationToken();
|
||||
|
||||
// Parse options
|
||||
var baseRef = context.ParseResult.GetValueForOption(BaseOption)!;
|
||||
var targetRef = context.ParseResult.GetValueForOption(TargetOption)!;
|
||||
// ...
|
||||
|
||||
// Execute diff
|
||||
var progress = new Progress<BinaryDiffProgress>(p =>
|
||||
_console.WriteLine($"[{p.Phase}] {p.CurrentItem} ({p.Current}/{p.Total})"));
|
||||
|
||||
var result = await _diffService.ComputeDiffAsync(
|
||||
new BinaryDiffRequest { ... },
|
||||
progress,
|
||||
cancellationToken);
|
||||
|
||||
// Emit DSSE if requested
|
||||
if (!string.IsNullOrEmpty(emitDssePath))
|
||||
{
|
||||
var dsseResult = await _signer.SignAsync(result.Predicate!, cancellationToken);
|
||||
await WriteDsseAsync(emitDssePath, result.Platform, dsseResult, cancellationToken);
|
||||
}
|
||||
|
||||
// Render output
|
||||
await _renderer.RenderAsync(result, format, _console.Out, cancellationToken);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
| Error | Exit Code | Message |
|
||||
|-------|-----------|---------|
|
||||
| Invalid base image | 1 | `Error: Unable to resolve base image '{ref}': {reason}` |
|
||||
| Invalid target image | 1 | `Error: Unable to resolve target image '{ref}': {reason}` |
|
||||
| Authentication failed | 2 | `Error: Registry authentication failed for '{registry}'` |
|
||||
| Platform not found | 3 | `Error: Platform '{platform}' not found in image index` |
|
||||
| No ELF binaries | 0 | `Warning: No ELF binaries found in images` (success with warning) |
|
||||
| Timeout | 124 | `Error: Operation timed out after {timeout}s` |
|
||||
| Network error | 5 | `Error: Network error: {message}` |
|
||||
|
||||
### Progress Reporting
|
||||
|
||||
```
|
||||
[pulling] Fetching base manifest... (1/4)
|
||||
[pulling] Fetching target manifest... (2/4)
|
||||
[pulling] Downloading layers... (3/4)
|
||||
└─ sha256:abc123... 45.2 MB/128.5 MB (35%)
|
||||
[extracting] Extracting base layers... (1/8)
|
||||
[extracting] Extracting target layers... (5/8)
|
||||
[analyzing] Computing section hashes... (1/156)
|
||||
└─ /usr/lib/libssl.so.3
|
||||
[analyzing] Computing section hashes... (78/156)
|
||||
└─ /usr/bin/python3.11
|
||||
[diffing] Comparing binaries... (1/156)
|
||||
[complete] Analysis complete.
|
||||
```
|
||||
|
||||
## Determinism Requirements
|
||||
|
||||
1. **Output ordering**: Findings sorted by path
|
||||
2. **Timestamps**: From injected `TimeProvider`
|
||||
3. **Hash formats**: Lowercase hexadecimal
|
||||
4. **JSON output**: RFC 8785 canonical when `--format=json`
|
||||
5. **DSSE files**: Canonical JSON serialization
|
||||
|
||||
## Test Cases
|
||||
|
||||
### Unit Tests
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `ParseOptions_ValidArgs_Succeeds` | All required options provided | Options parsed correctly |
|
||||
| `ParseOptions_MissingBase_Fails` | Missing --base | Parse error |
|
||||
| `ComputeDiff_IdenticalImages_NoChanges` | Same image for base and target | Empty findings, summary shows 0 modified |
|
||||
| `ComputeDiff_ModifiedBinary_DetectsChange` | Binary with .text change | Finding with modified status |
|
||||
| `ComputeDiff_AddedBinary_Detected` | Binary in target only | Finding with added status |
|
||||
| `ComputeDiff_RemovedBinary_Detected` | Binary in base only | Finding with removed status |
|
||||
| `RenderTable_ValidResult_FormatsCorrectly` | Result with findings | Properly formatted table |
|
||||
| `RenderJson_ValidResult_CanonicalOutput` | Same result, multiple renders | Byte-identical JSON |
|
||||
| `EmitDsse_ValidResult_CreatesFile` | With --emit-dsse | DSSE file created |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `EndToEnd_RealImages_ProducesOutput` | Two synthetic OCI images | Valid diff output |
|
||||
| `EndToEnd_WithDsse_ValidAttestation` | Diff with --emit-dsse | Verifiable DSSE |
|
||||
| `MultiArch_SpecificPlatform_FiltersCorrectly` | Multi-arch image with --platform | Only specified platform analyzed |
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint created from advisory analysis. | Project Mgmt |
|
||||
| 2026-01-13 | Task CLI-DIFF-TESTS-0001 marked BLOCKED: CLI tests under active modification. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED**: Command placed under `stella scan diff` (not separate `stella-scan image diff` as in advisory).
|
||||
- **APPROVED**: Support `--mode=elf` initially; `--mode=pe` and `--mode=auto` stubbed for future.
|
||||
- **BLOCKED**: CLI tests require coordination with other agent work; tests deferred.
|
||||
- **RISK**: Long-running operations need robust timeout and cancellation handling.
|
||||
- **RISK**: Large images may cause memory pressure; consider streaming approach for layer extraction.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Task 1-6 complete → Command implementation ready
|
||||
- Task 7-8 complete → Help and DI wired up
|
||||
- Task 9-10 complete (after unblock) → Sprint can be marked DONE
|
||||
@@ -0,0 +1,351 @@
|
||||
# Sprint 20260113_001_004_DOCS - Binary Diff Attestation Documentation
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
- Create architecture documentation for binary diff attestation feature
|
||||
- Update CLI reference with new `stella scan diff` command
|
||||
- Publish BinaryDiffV1 predicate JSON Schema
|
||||
- Add developer guide for extending binary analysis
|
||||
- **Working directory:** `docs/`
|
||||
|
||||
## Dependencies & Concurrency
|
||||
|
||||
- Can proceed in parallel with Sprints 2-3 (after Sprint 1 models stabilize)
|
||||
- No blocking dependencies for initial documentation drafts
|
||||
- Final documentation review after all implementation sprints complete
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
- `docs/README.md`
|
||||
- `docs/ARCHITECTURE_REFERENCE.md`
|
||||
- `CLAUDE.md` (for documentation standards)
|
||||
- Existing module docs: `docs/modules/scanner/`
|
||||
- Existing CLI docs: `docs/API_CLI_REFERENCE.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
|---|---------|--------|---------------------------|--------|-----------------|
|
||||
| 1 | DOCS-ARCH-0001 | TODO | Sprint 001 models | Guild - Docs | Create `docs/modules/scanner/binary-diff-attestation.md` architecture document covering ELF section hashing, diff computation, and DSSE attestation flow. |
|
||||
| 2 | DOCS-CLI-0001 | TODO | Sprint 003 command spec | Guild - Docs | Update `docs/API_CLI_REFERENCE.md` with `stella scan diff` command documentation including all options, examples, and output formats. |
|
||||
| 3 | DOCS-SCHEMA-0001 | TODO | Sprint 002 schema | Guild - Docs | Publish `docs/schemas/binarydiff-v1.schema.json` with full JSON Schema definition and validation examples. |
|
||||
| 4 | DOCS-DEVGUIDE-0001 | TODO | All sprints | Guild - Docs | Create `docs/dev/extending-binary-analysis.md` developer guide for adding new binary formats (PE, Mach-O) and custom section extractors. |
|
||||
| 5 | DOCS-EXAMPLES-0001 | TODO | Sprint 003 complete | Guild - Docs | Add usage examples to `docs/examples/binary-diff/` with sample commands, expected outputs, and DSSE verification steps. |
|
||||
| 6 | DOCS-GLOSSARY-0001 | TODO | None | Guild - Docs | Update `docs/GLOSSARY.md` (if exists) or create glossary entries for: section hash, binary diff, vendor backport, DSSE envelope. |
|
||||
| 7 | DOCS-CHANGELOG-0001 | TODO | All sprints complete | Guild - Docs | Add changelog entry for binary diff attestation feature in `CHANGELOG.md`. |
|
||||
| 8 | DOCS-REVIEW-0001 | TODO | All above complete | Guild - Docs | Final documentation review: cross-link all docs, verify examples work, spell-check, ensure consistency with existing docs. |
|
||||
|
||||
## Documentation Deliverables
|
||||
|
||||
### 1. Architecture Document
|
||||
|
||||
**File:** `docs/modules/scanner/binary-diff-attestation.md`
|
||||
|
||||
**Outline:**
|
||||
|
||||
```markdown
|
||||
# Binary Diff Attestation
|
||||
|
||||
## Overview
|
||||
- Purpose and use cases
|
||||
- Relationship to SBOM and VEX
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Diagram
|
||||
- ElfSectionHashExtractor
|
||||
- BinaryDiffService
|
||||
- BinaryDiffPredicateBuilder
|
||||
- BinaryDiffDsseSigner
|
||||
|
||||
### Data Flow
|
||||
1. Image resolution
|
||||
2. Layer extraction
|
||||
3. Binary identification
|
||||
4. Section hash computation
|
||||
5. Diff computation
|
||||
6. Predicate construction
|
||||
7. DSSE signing
|
||||
|
||||
## ELF Section Hashing
|
||||
|
||||
### Target Sections
|
||||
- .text (executable code)
|
||||
- .rodata (read-only data)
|
||||
- .data (initialized data)
|
||||
- .symtab (symbol table)
|
||||
- .dynsym (dynamic symbols)
|
||||
|
||||
### Hash Algorithm
|
||||
- SHA-256 primary
|
||||
- BLAKE3 optional
|
||||
|
||||
### Determinism Guarantees
|
||||
- Stable ordering
|
||||
- Canonical serialization
|
||||
|
||||
## BinaryDiffV1 Predicate
|
||||
|
||||
### Schema Overview
|
||||
- Subjects (image references)
|
||||
- Inputs (base/target)
|
||||
- Findings (per-binary deltas)
|
||||
- Metadata
|
||||
|
||||
### Evidence Properties
|
||||
- Section hashes in SBOM
|
||||
- Confidence scoring
|
||||
- Verdict classification
|
||||
|
||||
## DSSE Attestation
|
||||
|
||||
### Envelope Structure
|
||||
- Payload type: stellaops.binarydiff.v1
|
||||
- Signature algorithm
|
||||
- Rekor submission
|
||||
|
||||
### Verification
|
||||
- cosign compatibility
|
||||
- Offline verification
|
||||
|
||||
## Integration Points
|
||||
|
||||
### VEX Mapping
|
||||
- Linking to vulnerability status
|
||||
- Backport evidence
|
||||
|
||||
### Policy Engine
|
||||
- Binary evidence rules
|
||||
- Trust thresholds
|
||||
|
||||
## Configuration
|
||||
|
||||
### Options
|
||||
- Section selection
|
||||
- Hash algorithms
|
||||
- Output formats
|
||||
|
||||
## Limitations and Future Work
|
||||
|
||||
### Current Limitations
|
||||
- ELF only (PE/Mach-O planned)
|
||||
- Single-platform per invocation
|
||||
|
||||
### Roadmap
|
||||
- PE section analysis (M2)
|
||||
- Mach-O section analysis (M2)
|
||||
- Vendor backport corpus (M3)
|
||||
```
|
||||
|
||||
### 2. CLI Reference Update
|
||||
|
||||
**File:** `docs/API_CLI_REFERENCE.md` (append to Scan section)
|
||||
|
||||
**Content:**
|
||||
|
||||
```markdown
|
||||
### stella scan diff
|
||||
|
||||
Compare binaries between two container images at the section level.
|
||||
|
||||
#### Synopsis
|
||||
|
||||
```bash
|
||||
stella scan diff --base <image-ref> --target <image-ref> [options]
|
||||
```
|
||||
|
||||
#### Description
|
||||
|
||||
The `diff` command performs binary-level comparison between two container images,
|
||||
analyzing ELF section hashes to detect changes and classify them as patches,
|
||||
vanilla updates, or unknown modifications.
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--base`, `-b` | Base image reference (required) |
|
||||
| `--target`, `-t` | Target image reference (required) |
|
||||
| `--mode`, `-m` | Analysis mode: `elf`, `pe`, `auto` (default: `auto`) |
|
||||
| `--emit-dsse`, `-d` | Directory for DSSE attestation output |
|
||||
| `--format`, `-f` | Output format: `table`, `json`, `summary` (default: `table`) |
|
||||
| `--platform`, `-p` | Platform filter (e.g., `linux/amd64`) |
|
||||
| `--include-unchanged` | Include unchanged binaries in output |
|
||||
| `--sections` | Sections to analyze (comma-separated) |
|
||||
| `--registry-auth` | Path to Docker config for authentication |
|
||||
| `--timeout` | Timeout in seconds (default: 300) |
|
||||
| `--verbose`, `-v` | Enable verbose output |
|
||||
|
||||
#### Examples
|
||||
|
||||
**Basic comparison:**
|
||||
```bash
|
||||
stella scan diff --base myapp:1.0.0 --target myapp:1.0.1
|
||||
```
|
||||
|
||||
**With DSSE attestation output:**
|
||||
```bash
|
||||
stella scan diff -b myapp:1.0.0 -t myapp:1.0.1 \
|
||||
--mode=elf --emit-dsse=./attestations/
|
||||
```
|
||||
|
||||
**JSON output for automation:**
|
||||
```bash
|
||||
stella scan diff -b myapp:1.0.0 -t myapp:1.0.1 --format=json > diff.json
|
||||
```
|
||||
|
||||
**Specific platform:**
|
||||
```bash
|
||||
stella scan diff -b myapp:1.0.0 -t myapp:1.0.1 --platform=linux/arm64
|
||||
```
|
||||
|
||||
#### Output
|
||||
|
||||
**Table format** shows a summary of changes:
|
||||
```
|
||||
PATH CHANGE VERDICT CONFIDENCE
|
||||
/usr/lib/libssl.so.3 modified patched 0.95
|
||||
/usr/lib/libcrypto.so.3 modified patched 0.92
|
||||
```
|
||||
|
||||
**JSON format** provides full diff details for programmatic consumption.
|
||||
|
||||
#### Exit Codes
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| 0 | Success |
|
||||
| 1 | Invalid image reference |
|
||||
| 2 | Authentication failed |
|
||||
| 3 | Platform not found |
|
||||
| 124 | Timeout |
|
||||
| 5 | Network error |
|
||||
|
||||
#### See Also
|
||||
|
||||
- `stella scan layers` - List layers in an image
|
||||
- `stella scan sbom` - Generate SBOM for an image
|
||||
- [Binary Diff Attestation Architecture](../modules/scanner/binary-diff-attestation.md)
|
||||
```
|
||||
|
||||
### 3. JSON Schema
|
||||
|
||||
**File:** `docs/schemas/binarydiff-v1.schema.json`
|
||||
|
||||
(Full schema as defined in Sprint 002)
|
||||
|
||||
### 4. Developer Guide
|
||||
|
||||
**File:** `docs/dev/extending-binary-analysis.md`
|
||||
|
||||
**Outline:**
|
||||
|
||||
```markdown
|
||||
# Extending Binary Analysis
|
||||
|
||||
## Overview
|
||||
|
||||
This guide explains how to add support for new binary formats (PE, Mach-O)
|
||||
or custom section extractors to the binary diff attestation system.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Extractor Interface
|
||||
- ISectionHashExtractor<TConfig>
|
||||
- Registration pattern
|
||||
- Configuration binding
|
||||
|
||||
### Adding a New Format
|
||||
|
||||
#### Step 1: Define Models
|
||||
- Section hash models
|
||||
- Format-specific metadata
|
||||
|
||||
#### Step 2: Implement Extractor
|
||||
- Parse binary format
|
||||
- Extract sections
|
||||
- Compute hashes
|
||||
|
||||
#### Step 3: Register Services
|
||||
- DI registration
|
||||
- Configuration binding
|
||||
- Format detection
|
||||
|
||||
#### Step 4: Add Tests
|
||||
- Unit test fixtures
|
||||
- Golden file comparisons
|
||||
- Edge cases
|
||||
|
||||
### Example: PE Section Extractor
|
||||
|
||||
```csharp
|
||||
public class PeSectionHashExtractor : ISectionHashExtractor<PeConfig>
|
||||
{
|
||||
// Implementation example
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Determinism
|
||||
- Stable ordering
|
||||
- Canonical hashing
|
||||
- Injected dependencies
|
||||
|
||||
### Performance
|
||||
- Streaming large binaries
|
||||
- Caching strategies
|
||||
- Parallel extraction
|
||||
|
||||
### Security
|
||||
- Input validation
|
||||
- Memory limits
|
||||
- Malformed input handling
|
||||
```
|
||||
|
||||
### 5. Usage Examples
|
||||
|
||||
**Directory:** `docs/examples/binary-diff/`
|
||||
|
||||
**Files:**
|
||||
|
||||
```
|
||||
binary-diff/
|
||||
├── README.md # Overview and prerequisites
|
||||
├── basic-comparison.md # Simple diff example
|
||||
├── dsse-attestation.md # DSSE output and verification
|
||||
├── policy-integration.md # Using diffs in policy rules
|
||||
├── ci-cd-integration.md # GitHub Actions / GitLab CI examples
|
||||
└── sample-outputs/
|
||||
├── diff-table.txt # Sample table output
|
||||
├── diff.json # Sample JSON output
|
||||
└── attestation.dsse.json # Sample DSSE envelope
|
||||
```
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
- [ ] All code examples compile/run
|
||||
- [ ] All links are valid
|
||||
- [ ] Consistent terminology with existing docs
|
||||
- [ ] No spelling/grammar errors
|
||||
- [ ] Screenshots/diagrams where helpful
|
||||
- [ ] Cross-references to related docs
|
||||
- [ ] Version compatibility noted
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint created from advisory analysis. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED**: Documentation follows existing StellaOps documentation patterns.
|
||||
- **APPROVED**: JSON Schema published under `docs/schemas/` for external consumption.
|
||||
- **RISK**: Documentation may need updates if implementation details change; defer final review until code complete.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Task 1-3 complete → Core documentation in place
|
||||
- Task 4-5 complete → Developer and user resources ready
|
||||
- Task 8 complete → Sprint can be marked DONE
|
||||
@@ -0,0 +1,197 @@
|
||||
# Sprint Batch 20260113_002 - Image Index Resolution CLI
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This sprint batch implements **OCI multi-arch image inspection** capabilities, enabling users to enumerate image indices, platform manifests, and layer digests through CLI commands. This completes the "index -> manifests -> layers" flow requested in the OCI Layer-Level Image Integrity advisory.
|
||||
|
||||
**Scope:** OCI image index resolution with Docker & OCI media type support
|
||||
**Effort Estimate:** 4-5 story points across 3 sprints
|
||||
**Priority:** Medium (usability enhancement)
|
||||
|
||||
## Background
|
||||
|
||||
### Advisory Requirements
|
||||
|
||||
The original advisory specified:
|
||||
|
||||
> Resolve an image index (if present), list all platform manifests, then for each manifest list ordered layer digests and sizes. Accept Docker and OCI media types.
|
||||
|
||||
### Existing Capabilities
|
||||
|
||||
| Component | Status | Location |
|
||||
|-----------|--------|----------|
|
||||
| `OciIndex` record | EXISTS | `src/Concelier/__Libraries/.../OciIndex.cs` |
|
||||
| `OciManifest` record | EXISTS | `src/Concelier/__Libraries/.../OciManifest.cs` |
|
||||
| `OciRegistryClient` | EXISTS | `src/Excititor/__Libraries/.../Fetch/OciRegistryClient.cs` |
|
||||
| `OciImageReferenceParser` | EXISTS | `src/Cli/StellaOps.Cli/Services/OciImageReferenceParser.cs` |
|
||||
| `LayeredRootFileSystem` | EXISTS | `src/Scanner/__Libraries/.../FileSystem/LayeredRootFileSystem.cs` |
|
||||
|
||||
### Gap Analysis
|
||||
|
||||
| Capability | Status |
|
||||
|------------|--------|
|
||||
| Parse OCI image index from registry | Partial (records exist, no handler) |
|
||||
| Walk index -> platform manifests | MISSING |
|
||||
| CLI `image inspect` verb | MISSING |
|
||||
| JSON output with canonical digests | MISSING |
|
||||
|
||||
## Sprint Index
|
||||
|
||||
| Sprint | ID | Module | Topic | Status | Owner |
|
||||
|--------|-----|--------|-------|--------|-------|
|
||||
| 1 | SPRINT_20260113_002_001 | SCANNER | OCI Image Index Inspector Service | TODO | Guild - Scanner |
|
||||
| 2 | SPRINT_20260113_002_002 | CLI | Image Inspect Command | TODO | Guild - CLI |
|
||||
| 3 | SPRINT_20260113_002_003 | DOCS | Image Inspection Documentation | TODO | Guild - Docs |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
+-----------------------------------------------------------------------+
|
||||
| Dependency Graph |
|
||||
+-----------------------------------------------------------------------+
|
||||
| |
|
||||
| Sprint 1 (Inspector Service) |
|
||||
| | |
|
||||
| +------------------+ |
|
||||
| v v |
|
||||
| Sprint 2 (CLI) Sprint 3 (Docs) |
|
||||
| |
|
||||
+-----------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
- **Sprint 1** is foundational (no dependencies)
|
||||
- **Sprint 2** depends on Sprint 1 (uses inspector service)
|
||||
- **Sprint 3** can proceed in parallel with Sprint 2
|
||||
|
||||
**Cross-Batch Dependencies:**
|
||||
- None (this batch is independent of 001)
|
||||
|
||||
## Acceptance Criteria (Batch-Level)
|
||||
|
||||
### Must Have
|
||||
|
||||
1. **Image Index Resolution**
|
||||
- Accept image reference (tag or digest)
|
||||
- Detect and parse image index (multi-arch) vs single manifest
|
||||
- Return platform manifest list with os/arch/variant
|
||||
|
||||
2. **Layer Enumeration**
|
||||
- For each platform manifest: ordered layer digests
|
||||
- Include layer sizes and media types
|
||||
- Support both Docker and OCI media types
|
||||
|
||||
3. **CLI Command**
|
||||
- `stella image inspect <reference>` with output formats
|
||||
- `--resolve-index` flag to walk multi-arch structure
|
||||
- `--print-layers` flag to include layer details
|
||||
- JSON output with canonical ordering
|
||||
|
||||
4. **Documentation**
|
||||
- CLI reference for new commands
|
||||
- Architecture doc for inspector service
|
||||
|
||||
### Should Have
|
||||
|
||||
- Platform filtering (`--platform linux/amd64`)
|
||||
- Config blob inspection (`--config` flag)
|
||||
- Cache manifest responses (in-memory, session-scoped)
|
||||
|
||||
### Deferred (Out of Scope)
|
||||
|
||||
- `skopeo` or `ctr` CLI integration (use HTTP API)
|
||||
- Offline image tar inspection (handled by existing LayeredRootFileSystem)
|
||||
- Image pulling/export (out of scope)
|
||||
|
||||
## Technical Context
|
||||
|
||||
### Key Files to Create/Extend
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| Inspector Service | `src/Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/OciImageInspector.cs` | NEW: Unified index/manifest inspection |
|
||||
| Inspector Models | `src/Scanner/__Libraries/StellaOps.Scanner.Contracts/OciInspectionModels.cs` | NEW: Inspection result models |
|
||||
| CLI Command | `src/Cli/StellaOps.Cli/Commands/ImageCommandGroup.cs` | NEW: `stella image` command group |
|
||||
| CLI Handler | `src/Cli/StellaOps.Cli/Commands/CommandHandlers.Image.cs` | NEW: Image command handlers |
|
||||
|
||||
### Output Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"reference": "docker.io/library/nginx:latest",
|
||||
"resolvedDigest": "sha256:abc123...",
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"isMultiArch": true,
|
||||
"platforms": [
|
||||
{
|
||||
"os": "linux",
|
||||
"architecture": "amd64",
|
||||
"variant": null,
|
||||
"manifestDigest": "sha256:def456...",
|
||||
"configDigest": "sha256:ghi789...",
|
||||
"layers": [
|
||||
{
|
||||
"order": 0,
|
||||
"digest": "sha256:layer1...",
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
"size": 31457280
|
||||
}
|
||||
],
|
||||
"totalSize": 157286400
|
||||
}
|
||||
],
|
||||
"inspectedAt": "2026-01-13T12:00:00Z",
|
||||
"inspectorVersion": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
### Determinism Requirements
|
||||
|
||||
Per CLAUDE.md Section 8:
|
||||
|
||||
1. **Ordering**: Platforms sorted by os/arch/variant; layers by order
|
||||
2. **Timestamps**: From injected `TimeProvider`
|
||||
3. **JSON serialization**: Canonical key ordering
|
||||
4. **InvariantCulture**: All size/number formatting
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Registry auth complexity | Medium | Medium | Use existing `OciRegistryClient` auth handling |
|
||||
| Rate limiting on public registries | Low | Low | Implement retry with backoff |
|
||||
| Non-standard manifest schemas | Low | Medium | Graceful degradation with warnings |
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- [ ] All unit tests pass
|
||||
- [ ] Integration tests against Docker Hub, GHCR, and mock registry
|
||||
- [ ] CLI completions and help work correctly
|
||||
- [ ] JSON output is valid and deterministic
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
Before starting implementation, reviewers must read:
|
||||
|
||||
- `docs/README.md`
|
||||
- `docs/ARCHITECTURE_REFERENCE.md`
|
||||
- `CLAUDE.md` Section 8 (Code Quality & Determinism Rules)
|
||||
- OCI Image Index Spec: https://github.com/opencontainers/image-spec/blob/main/image-index.md
|
||||
- OCI Image Manifest Spec: https://specs.opencontainers.org/image-spec/manifest/
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint batch created from advisory analysis. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED 2026-01-13**: Use HTTP Registry API v2 only; no external CLI tool dependencies.
|
||||
- **APPROVED 2026-01-13**: Single-manifest images return as degenerate case (1-element platform list).
|
||||
- **RISK**: Some registries may not support OCI index; handle Docker manifest list as fallback.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Sprint 1 completion -> Sprint 2 can start
|
||||
- All sprints complete -> Integration testing checkpoint
|
||||
- Integrate with Batch 001 CLI commands post-completion
|
||||
@@ -0,0 +1,271 @@
|
||||
# Sprint 20260113_002_001_SCANNER - OCI Image Index Inspector Service
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
- Implement unified OCI image inspection service
|
||||
- Support image index (multi-arch) and single manifest resolution
|
||||
- Walk index -> platform manifests -> ordered layers
|
||||
- Support both Docker and OCI media types
|
||||
- **Working directory:** `src/Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/`
|
||||
|
||||
## Dependencies & Concurrency
|
||||
|
||||
- No blocking dependencies (foundational sprint)
|
||||
- Uses existing `OciRegistryClient` for HTTP operations
|
||||
- Sprint 2 (CLI) depends on this sprint
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
- `docs/README.md`
|
||||
- `docs/ARCHITECTURE_REFERENCE.md`
|
||||
- `CLAUDE.md` Section 8 (Determinism Rules)
|
||||
- OCI Image Index Spec: https://github.com/opencontainers/image-spec/blob/main/image-index.md
|
||||
- OCI Image Manifest Spec: https://specs.opencontainers.org/image-spec/manifest/
|
||||
- Docker Manifest List: https://docs.docker.com/registry/spec/manifest-v2-2/
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
|---|---------|--------|---------------------------|--------|-----------------|
|
||||
| 1 | IMG-INSPECT-MODELS-0001 | TODO | None | Guild - Scanner | Define `ImageInspectionResult`, `PlatformManifest`, `LayerInfo` models in `src/Scanner/__Libraries/StellaOps.Scanner.Contracts/OciInspectionModels.cs`. Include all OCI/Docker discriminators. |
|
||||
| 2 | IMG-INSPECT-INTERFACE-0001 | TODO | Depends on MODELS-0001 | Guild - Scanner | Define `IOciImageInspector` interface with `InspectAsync(reference, options, ct)` signature. Options include: resolveIndex, includeLayers, platformFilter. |
|
||||
| 3 | IMG-INSPECT-IMPL-0001 | TODO | Depends on INTERFACE-0001 | Guild - Scanner | Implement `OciImageInspector` class. Handle HEAD request for manifest detection, then GET for content. Detect index vs manifest by media type. |
|
||||
| 4 | IMG-INSPECT-INDEX-0001 | TODO | Depends on IMPL-0001 | Guild - Scanner | Implement index resolution: parse `application/vnd.oci.image.index.v1+json` and `application/vnd.docker.distribution.manifest.list.v2+json`. Extract platform descriptors. |
|
||||
| 5 | IMG-INSPECT-MANIFEST-0001 | TODO | Depends on IMPL-0001 | Guild - Scanner | Implement manifest parsing: `application/vnd.oci.image.manifest.v1+json` and `application/vnd.docker.distribution.manifest.v2+json`. Extract config and layers. |
|
||||
| 6 | IMG-INSPECT-LAYERS-0001 | TODO | Depends on MANIFEST-0001 | Guild - Scanner | For each manifest, enumerate layers with: order (0-indexed), digest, mediaType, size. Support compressed and uncompressed variants. |
|
||||
| 7 | IMG-INSPECT-AUTH-0001 | TODO | Depends on IMPL-0001 | Guild - Scanner | Integrate with existing registry auth: token-based, basic, anonymous. Handle 401 -> token refresh flow. |
|
||||
| 8 | IMG-INSPECT-DI-0001 | TODO | Depends on all above | Guild - Scanner | Register `IOciImageInspector` in `ServiceCollectionExtensions.cs`. Inject `TimeProvider`, `IHttpClientFactory`, `ILogger`. |
|
||||
| 9 | IMG-INSPECT-TESTS-0001 | TODO | Depends on all above | Guild - Scanner | Unit tests covering: single manifest, multi-arch index, Docker manifest list, missing manifest, auth errors, malformed responses. |
|
||||
| 10 | IMG-INSPECT-INTEGRATION-0001 | TODO | Depends on TESTS-0001 | Guild - Scanner | Integration tests against mock OCI registry (testcontainers or in-memory). Test real Docker Hub and GHCR in CI. |
|
||||
|
||||
## Technical Specification
|
||||
|
||||
### Models
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Scanner.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Result of inspecting an OCI image reference.
|
||||
/// </summary>
|
||||
public sealed record ImageInspectionResult
|
||||
{
|
||||
/// <summary>Original image reference provided.</summary>
|
||||
public required string Reference { get; init; }
|
||||
|
||||
/// <summary>Resolved digest of the index or manifest.</summary>
|
||||
public required string ResolvedDigest { get; init; }
|
||||
|
||||
/// <summary>Media type of the resolved artifact.</summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>True if this is a multi-arch image index.</summary>
|
||||
public required bool IsMultiArch { get; init; }
|
||||
|
||||
/// <summary>Platform manifests (1 for single-arch, N for multi-arch).</summary>
|
||||
public required ImmutableArray<PlatformManifest> Platforms { get; init; }
|
||||
|
||||
/// <summary>Inspection timestamp (UTC).</summary>
|
||||
public required DateTimeOffset InspectedAt { get; init; }
|
||||
|
||||
/// <summary>Inspector version for reproducibility.</summary>
|
||||
public required string InspectorVersion { get; init; }
|
||||
|
||||
/// <summary>Registry that was queried.</summary>
|
||||
public required string Registry { get; init; }
|
||||
|
||||
/// <summary>Repository name.</summary>
|
||||
public required string Repository { get; init; }
|
||||
|
||||
/// <summary>Warnings encountered during inspection.</summary>
|
||||
public ImmutableArray<string> Warnings { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A platform-specific manifest within an image index.
|
||||
/// </summary>
|
||||
public sealed record PlatformManifest
|
||||
{
|
||||
/// <summary>Operating system (e.g., "linux", "windows").</summary>
|
||||
public required string Os { get; init; }
|
||||
|
||||
/// <summary>CPU architecture (e.g., "amd64", "arm64").</summary>
|
||||
public required string Architecture { get; init; }
|
||||
|
||||
/// <summary>Architecture variant (e.g., "v8" for arm64).</summary>
|
||||
public string? Variant { get; init; }
|
||||
|
||||
/// <summary>OS version (mainly for Windows).</summary>
|
||||
public string? OsVersion { get; init; }
|
||||
|
||||
/// <summary>Digest of this platform's manifest.</summary>
|
||||
public required string ManifestDigest { get; init; }
|
||||
|
||||
/// <summary>Media type of the manifest.</summary>
|
||||
public required string ManifestMediaType { get; init; }
|
||||
|
||||
/// <summary>Digest of the config blob.</summary>
|
||||
public required string ConfigDigest { get; init; }
|
||||
|
||||
/// <summary>Ordered list of layers.</summary>
|
||||
public required ImmutableArray<LayerInfo> Layers { get; init; }
|
||||
|
||||
/// <summary>Total size of all layers in bytes.</summary>
|
||||
public required long TotalSize { get; init; }
|
||||
|
||||
/// <summary>Platform string (os/arch/variant).</summary>
|
||||
public string PlatformString => Variant is null
|
||||
? $"{Os}/{Architecture}"
|
||||
: $"{Os}/{Architecture}/{Variant}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a single layer.
|
||||
/// </summary>
|
||||
public sealed record LayerInfo
|
||||
{
|
||||
/// <summary>Layer order (0-indexed, application order).</summary>
|
||||
public required int Order { get; init; }
|
||||
|
||||
/// <summary>Layer digest (sha256:...).</summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>Media type of the layer blob.</summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>Compressed size in bytes.</summary>
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>Optional annotations from the manifest.</summary>
|
||||
public ImmutableDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### Interface
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
public interface IOciImageInspector
|
||||
{
|
||||
/// <summary>
|
||||
/// Inspects an OCI image reference.
|
||||
/// </summary>
|
||||
/// <param name="reference">Image reference (e.g., "nginx:latest", "ghcr.io/org/app@sha256:...").</param>
|
||||
/// <param name="options">Inspection options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Inspection result or null if not found.</returns>
|
||||
Task<ImageInspectionResult?> InspectAsync(
|
||||
string reference,
|
||||
ImageInspectionOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record ImageInspectionOptions
|
||||
{
|
||||
/// <summary>Resolve multi-arch index to platform manifests (default: true).</summary>
|
||||
public bool ResolveIndex { get; init; } = true;
|
||||
|
||||
/// <summary>Include layer details (default: true).</summary>
|
||||
public bool IncludeLayers { get; init; } = true;
|
||||
|
||||
/// <summary>Filter to specific platform (e.g., "linux/amd64").</summary>
|
||||
public string? PlatformFilter { get; init; }
|
||||
|
||||
/// <summary>Maximum platforms to inspect (default: unlimited).</summary>
|
||||
public int? MaxPlatforms { get; init; }
|
||||
|
||||
/// <summary>Request timeout.</summary>
|
||||
public TimeSpan? Timeout { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### Media Type Handling
|
||||
|
||||
| Media Type | Type | Handling |
|
||||
|------------|------|----------|
|
||||
| `application/vnd.oci.image.index.v1+json` | OCI Index | Parse as index, enumerate manifests |
|
||||
| `application/vnd.docker.distribution.manifest.list.v2+json` | Docker List | Parse as index (compatible) |
|
||||
| `application/vnd.oci.image.manifest.v1+json` | OCI Manifest | Parse as manifest, extract layers |
|
||||
| `application/vnd.docker.distribution.manifest.v2+json` | Docker Manifest | Parse as manifest (compatible) |
|
||||
| Other | Unknown | Return warning, skip or fail per config |
|
||||
|
||||
### Algorithm
|
||||
|
||||
```pseudo
|
||||
function InspectAsync(reference, options):
|
||||
parsed = ParseReference(reference) // registry, repo, tag/digest
|
||||
|
||||
// Step 1: Resolve to digest
|
||||
digest = HEAD(registry, repo, parsed.tagOrDigest)
|
||||
mediaType = response.headers["Content-Type"]
|
||||
|
||||
// Step 2: Get manifest content
|
||||
body = GET(registry, repo, digest, Accept: mediaType)
|
||||
|
||||
// Step 3: Classify and parse
|
||||
if mediaType in [OCI_INDEX, DOCKER_MANIFEST_LIST]:
|
||||
index = ParseIndex(body)
|
||||
platforms = []
|
||||
for descriptor in index.manifests:
|
||||
if options.platformFilter and not matches(descriptor, filter):
|
||||
continue
|
||||
manifest = await InspectManifest(registry, repo, descriptor.digest)
|
||||
platforms.append(manifest)
|
||||
return Result(isMultiArch=true, platforms)
|
||||
else:
|
||||
manifest = ParseManifest(body)
|
||||
platform = ExtractPlatform(manifest.config)
|
||||
layers = ExtractLayers(manifest)
|
||||
return Result(isMultiArch=false, [platform])
|
||||
```
|
||||
|
||||
### Determinism Requirements
|
||||
|
||||
1. **Platform ordering**: Sort by os ASC, architecture ASC, variant ASC
|
||||
2. **Layer ordering**: Preserve manifest order (0-indexed)
|
||||
3. **Timestamps**: From injected `TimeProvider`
|
||||
4. **JSON**: Canonical serialization for any digest computation
|
||||
5. **Warnings**: Sorted lexicographically
|
||||
|
||||
## Test Cases
|
||||
|
||||
### Unit Tests
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `Inspect_SingleManifest_ReturnsSinglePlatform` | Image without index | 1 platform, layers present |
|
||||
| `Inspect_MultiArchIndex_ReturnsAllPlatforms` | Image with 5 platforms | 5 platforms, each with layers |
|
||||
| `Inspect_DockerManifestList_Parses` | Legacy Docker format | Correctly parsed as index |
|
||||
| `Inspect_PlatformFilter_ReturnsFiltered` | Filter to linux/amd64 | Only matching platform returned |
|
||||
| `Inspect_NotFound_ReturnsNull` | 404 response | Returns null, no exception |
|
||||
| `Inspect_AuthRequired_RefreshesToken` | 401 -> token refresh | Successful after refresh |
|
||||
| `Inspect_Deterministic_SameOutput` | Same image, multiple calls | Identical result (ignoring timestamp) |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `Inspect_DockerHub_NginxLatest` | Public Docker Hub image | Multi-arch result with linux/amd64, linux/arm64 |
|
||||
| `Inspect_GHCR_PublicImage` | GitHub Container Registry | Valid result |
|
||||
| `Inspect_MockRegistry_AllScenarios` | Testcontainers registry | All edge cases covered |
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint created from advisory analysis. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED**: Single manifest images return as 1-element platforms array for API consistency.
|
||||
- **APPROVED**: Use existing `OciRegistryClient` for HTTP operations where compatible.
|
||||
- **RISK**: Some registries return incorrect Content-Type; handle by sniffing JSON structure.
|
||||
- **RISK**: Large multi-arch images (10+ platforms) may be slow; add max_platforms limit.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Task 1-3 complete -> Basic inspection working
|
||||
- Task 4-6 complete -> Full index/manifest/layer resolution
|
||||
- Task 9-10 complete -> Sprint can be marked DONE
|
||||
- Unblock Sprint 2 (CLI)
|
||||
@@ -0,0 +1,283 @@
|
||||
# Sprint 20260113_002_002_CLI - Image Inspect Command
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
- Implement `stella image inspect` CLI command
|
||||
- Support `--resolve-index`, `--print-layers`, `--platform` flags
|
||||
- JSON and human-readable output formats
|
||||
- Integrate with OCI Image Inspector service
|
||||
- **Working directory:** `src/Cli/StellaOps.Cli/Commands/`
|
||||
|
||||
## Dependencies & Concurrency
|
||||
|
||||
- **Depends on:** Sprint 002_001 (OCI Image Inspector Service)
|
||||
- Parallel work safe within CLI module
|
||||
- Sprint 3 (Docs) can proceed in parallel
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
- `docs/README.md`
|
||||
- `CLAUDE.md` Section 8 (Determinism Rules)
|
||||
- `src/Cli/StellaOps.Cli/AGENTS.md` (if exists)
|
||||
- Existing CLI patterns in `LayerSbomCommandGroup.cs`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
|---|---------|--------|---------------------------|--------|-----------------|
|
||||
| 1 | CLI-IMAGE-GROUP-0001 | TODO | None | Guild - CLI | Create `ImageCommandGroup.cs` with `stella image` root command and subcommand registration. |
|
||||
| 2 | CLI-IMAGE-INSPECT-0001 | TODO | Depends on GROUP-0001 | Guild - CLI | Implement `stella image inspect <reference>` command with options: `--resolve-index`, `--print-layers`, `--platform`, `--output`. |
|
||||
| 3 | CLI-IMAGE-HANDLER-0001 | TODO | Depends on INSPECT-0001, Sprint 001 service | Guild - CLI | Implement `CommandHandlers.Image.cs` with `HandleInspectImageAsync` that calls `IOciImageInspector`. |
|
||||
| 4 | CLI-IMAGE-OUTPUT-TABLE-0001 | TODO | Depends on HANDLER-0001 | Guild - CLI | Implement table output for human-readable display using Spectre.Console. Show platforms, layers, sizes. |
|
||||
| 5 | CLI-IMAGE-OUTPUT-JSON-0001 | TODO | Depends on HANDLER-0001 | Guild - CLI | Implement JSON output with canonical ordering. Match schema from Sprint 001 models. |
|
||||
| 6 | CLI-IMAGE-REGISTER-0001 | TODO | Depends on all above | Guild - CLI | Register `ImageCommandGroup` in `CommandFactory.cs`. Wire DI for `IOciImageInspector`. |
|
||||
| 7 | CLI-IMAGE-TESTS-0001 | TODO | Depends on all above | Guild - CLI | Unit tests covering: successful inspect, not found, auth error, invalid reference, output formats. |
|
||||
| 8 | CLI-IMAGE-GOLDEN-0001 | TODO | Depends on TESTS-0001 | Guild - CLI | Golden output tests for determinism: same input produces identical output across runs. |
|
||||
|
||||
## Technical Specification
|
||||
|
||||
### Command Structure
|
||||
|
||||
```
|
||||
stella image <subcommand>
|
||||
|
||||
Subcommands:
|
||||
inspect Inspect OCI image manifest and layers
|
||||
```
|
||||
|
||||
### `stella image inspect` Command
|
||||
|
||||
```
|
||||
stella image inspect <reference> [options]
|
||||
|
||||
Arguments:
|
||||
<reference> Image reference (e.g., nginx:latest, ghcr.io/org/app@sha256:...)
|
||||
|
||||
Options:
|
||||
--resolve-index, -r Resolve multi-arch index to platform manifests (default: true)
|
||||
--print-layers, -l Include layer details in output (default: true)
|
||||
--platform, -p Filter to specific platform (e.g., linux/amd64)
|
||||
--output, -o Output format: table (default), json
|
||||
--verbose, -v Show detailed information including warnings
|
||||
--timeout Request timeout in seconds (default: 60)
|
||||
|
||||
Examples:
|
||||
stella image inspect nginx:latest
|
||||
stella image inspect nginx:latest --output json
|
||||
stella image inspect nginx:latest --platform linux/arm64
|
||||
stella image inspect ghcr.io/org/app@sha256:abc123... --print-layers
|
||||
```
|
||||
|
||||
### Output Examples
|
||||
|
||||
#### Table Output (Default)
|
||||
|
||||
```
|
||||
Image: nginx:latest
|
||||
Resolved Digest: sha256:abc123...
|
||||
Media Type: application/vnd.oci.image.index.v1+json
|
||||
Multi-Arch: Yes (5 platforms)
|
||||
|
||||
Platforms:
|
||||
+-------+--------------+----------+---------+---------------+------------+
|
||||
| OS | Architecture | Variant | Layers | Total Size | Manifest |
|
||||
+-------+--------------+----------+---------+---------------+------------+
|
||||
| linux | amd64 | - | 7 | 142.3 MB | sha256:... |
|
||||
| linux | arm64 | v8 | 7 | 138.1 MB | sha256:... |
|
||||
| linux | arm | v7 | 7 | 135.2 MB | sha256:... |
|
||||
| linux | 386 | - | 7 | 145.8 MB | sha256:... |
|
||||
| linux | ppc64le | - | 7 | 148.5 MB | sha256:... |
|
||||
+-------+--------------+----------+---------+---------------+------------+
|
||||
|
||||
Layers (linux/amd64):
|
||||
+-------+------------------+------------------------------------------------+----------+
|
||||
| Order | Size | Digest | Type |
|
||||
+-------+------------------+------------------------------------------------+----------+
|
||||
| 0 | 31.4 MB | sha256:a803e7c4b030... | tar+gzip |
|
||||
| 1 | 62.5 MB | sha256:8a6e7b1c9d2e... | tar+gzip |
|
||||
| ... | ... | ... | ... |
|
||||
+-------+------------------+------------------------------------------------+----------+
|
||||
|
||||
Inspected at: 2026-01-13T12:00:00Z
|
||||
```
|
||||
|
||||
#### JSON Output
|
||||
|
||||
```json
|
||||
{
|
||||
"reference": "nginx:latest",
|
||||
"resolvedDigest": "sha256:abc123...",
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"isMultiArch": true,
|
||||
"registry": "docker.io",
|
||||
"repository": "library/nginx",
|
||||
"platforms": [
|
||||
{
|
||||
"os": "linux",
|
||||
"architecture": "amd64",
|
||||
"variant": null,
|
||||
"manifestDigest": "sha256:def456...",
|
||||
"configDigest": "sha256:ghi789...",
|
||||
"layers": [
|
||||
{
|
||||
"order": 0,
|
||||
"digest": "sha256:layer1...",
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
"size": 31457280
|
||||
}
|
||||
],
|
||||
"totalSize": 157286400
|
||||
}
|
||||
],
|
||||
"inspectedAt": "2026-01-13T12:00:00Z",
|
||||
"inspectorVersion": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```csharp
|
||||
// ImageCommandGroup.cs
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
public static class ImageCommandGroup
|
||||
{
|
||||
public static Command Build(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var imageCommand = new Command("image", "OCI image operations");
|
||||
|
||||
imageCommand.AddCommand(BuildInspectCommand(services, options, verboseOption, cancellationToken));
|
||||
|
||||
return imageCommand;
|
||||
}
|
||||
|
||||
private static Command BuildInspectCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var referenceArg = new Argument<string>("reference")
|
||||
{
|
||||
Description = "Image reference (e.g., nginx:latest, ghcr.io/org/app@sha256:...)"
|
||||
};
|
||||
|
||||
var resolveIndexOption = new Option<bool>("--resolve-index", new[] { "-r" })
|
||||
{
|
||||
Description = "Resolve multi-arch index to platform manifests",
|
||||
DefaultValue = true
|
||||
};
|
||||
|
||||
var printLayersOption = new Option<bool>("--print-layers", new[] { "-l" })
|
||||
{
|
||||
Description = "Include layer details in output",
|
||||
DefaultValue = true
|
||||
};
|
||||
|
||||
var platformOption = new Option<string?>("--platform", new[] { "-p" })
|
||||
{
|
||||
Description = "Filter to specific platform (e.g., linux/amd64)"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format: table (default), json"
|
||||
};
|
||||
|
||||
var timeoutOption = new Option<int>("--timeout")
|
||||
{
|
||||
Description = "Request timeout in seconds",
|
||||
DefaultValue = 60
|
||||
};
|
||||
|
||||
var inspect = new Command("inspect", "Inspect OCI image manifest and layers")
|
||||
{
|
||||
referenceArg,
|
||||
resolveIndexOption,
|
||||
printLayersOption,
|
||||
platformOption,
|
||||
outputOption,
|
||||
timeoutOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
inspect.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var reference = parseResult.GetValue(referenceArg) ?? string.Empty;
|
||||
var resolveIndex = parseResult.GetValue(resolveIndexOption);
|
||||
var printLayers = parseResult.GetValue(printLayersOption);
|
||||
var platform = parseResult.GetValue(platformOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var timeout = parseResult.GetValue(timeoutOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await CommandHandlers.HandleInspectImageAsync(
|
||||
services, reference, resolveIndex, printLayers,
|
||||
platform, output, timeout, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return inspect;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
| Scenario | Exit Code | Message |
|
||||
|----------|-----------|---------|
|
||||
| Success | 0 | (output) |
|
||||
| Image not found | 1 | `Error: Image not found: <reference>` |
|
||||
| Auth required | 2 | `Error: Authentication required for <registry>` |
|
||||
| Invalid reference | 2 | `Error: Invalid image reference: <reference>` |
|
||||
| Network error | 2 | `Error: Network error: <message>` |
|
||||
| Timeout | 2 | `Error: Request timed out` |
|
||||
|
||||
### Determinism Requirements
|
||||
|
||||
1. **Ordering**: JSON keys sorted; platforms sorted by os/arch/variant
|
||||
2. **Size formatting**: Use InvariantCulture for all numbers
|
||||
3. **Timestamps**: Display as UTC ISO-8601
|
||||
4. **Digest truncation**: Consistent truncation (e.g., first 12 chars for display)
|
||||
|
||||
## Test Cases
|
||||
|
||||
### Unit Tests
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `Inspect_ValidReference_ReturnsSuccess` | Mock successful inspection | Exit code 0, valid output |
|
||||
| `Inspect_NotFound_ReturnsError` | 404 from registry | Exit code 1, error message |
|
||||
| `Inspect_InvalidReference_ReturnsError` | Malformed reference | Exit code 2, validation error |
|
||||
| `Inspect_JsonOutput_ValidJson` | Request JSON format | Parseable JSON output |
|
||||
| `Inspect_TableOutput_FormatsCorrectly` | Default table format | Table with headers and rows |
|
||||
| `Inspect_PlatformFilter_FiltersResults` | Filter to linux/amd64 | Only matching platform in output |
|
||||
|
||||
### Golden Output Tests
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `Inspect_Json_Deterministic` | Same input, multiple runs | Byte-identical JSON |
|
||||
| `Inspect_Table_Deterministic` | Same input, multiple runs | Identical table output |
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint created from advisory analysis. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED**: Table output as default (more user-friendly).
|
||||
- **APPROVED**: JSON output matches service model exactly (no transformation).
|
||||
- **RISK**: CLI tests may conflict with other agent work; coordinate ownership.
|
||||
- **RISK**: Table formatting may truncate long digests; use consistent truncation.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Task 1-3 complete -> Basic command working
|
||||
- Task 4-5 complete -> Both output formats working
|
||||
- Task 7-8 complete -> Sprint can be marked DONE
|
||||
102
docs/implplan/SPRINT_20260113_002_003_DOCS_image_inspection.md
Normal file
102
docs/implplan/SPRINT_20260113_002_003_DOCS_image_inspection.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Sprint 20260113_002_003_DOCS - Image Inspection Documentation
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
- Document OCI Image Inspector architecture
|
||||
- Create CLI reference for `stella image inspect`
|
||||
- Add usage examples and troubleshooting guide
|
||||
- **Working directory:** `docs/`
|
||||
|
||||
## Dependencies & Concurrency
|
||||
|
||||
- Can proceed in parallel with Sprint 002_002
|
||||
- Should finalize after Sprint 002_001 models are stable
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
|---|---------|--------|---------------------------|--------|-----------------|
|
||||
| 1 | DOCS-IMAGE-ARCH-0001 | TODO | Sprint 001 complete | Guild - Docs | Create `docs/modules/scanner/image-inspection.md` documenting the OCI Image Inspector service architecture, supported media types, and integration points. |
|
||||
| 2 | DOCS-IMAGE-CLI-0001 | TODO | Sprint 002 complete | Guild - Docs | Add `stella image inspect` to CLI reference in `docs/API_CLI_REFERENCE.md`. Include all options, examples, and exit codes. |
|
||||
| 3 | DOCS-IMAGE-EXAMPLES-0001 | TODO | Depends on CLI-0001 | Guild - Docs | Create practical usage examples in `docs/guides/image-inspection-guide.md` covering Docker Hub, GHCR, private registries, and CI/CD integration. |
|
||||
| 4 | DOCS-IMAGE-TROUBLESHOOT-0001 | TODO | Depends on EXAMPLES-0001 | Guild - Docs | Add troubleshooting section for common issues: auth failures, rate limits, unsupported media types. |
|
||||
|
||||
## Technical Specification
|
||||
|
||||
### Architecture Documentation Outline
|
||||
|
||||
```markdown
|
||||
# OCI Image Inspection
|
||||
|
||||
## Overview
|
||||
- Purpose and use cases
|
||||
- Supported registries and media types
|
||||
|
||||
## Architecture
|
||||
- IOciImageInspector interface
|
||||
- Index vs manifest resolution flow
|
||||
- Platform enumeration algorithm
|
||||
|
||||
## Media Type Support
|
||||
| Media Type | Description | Support |
|
||||
|------------|-------------|---------|
|
||||
| ... | ... | ... |
|
||||
|
||||
## Integration Points
|
||||
- CLI integration
|
||||
- Programmatic usage
|
||||
- Webhook/CI integration
|
||||
|
||||
## Configuration
|
||||
- Registry authentication
|
||||
- Timeout and retry settings
|
||||
|
||||
## Determinism
|
||||
- Output ordering guarantees
|
||||
- Reproducibility considerations
|
||||
```
|
||||
|
||||
### CLI Reference Addition
|
||||
|
||||
```markdown
|
||||
## stella image inspect
|
||||
|
||||
Inspect OCI image manifest and layers.
|
||||
|
||||
### Synopsis
|
||||
stella image inspect <reference> [options]
|
||||
|
||||
### Arguments
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| reference | Image reference (tag or digest) |
|
||||
|
||||
### Options
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| --resolve-index, -r | Resolve multi-arch index | true |
|
||||
| --print-layers, -l | Include layer details | true |
|
||||
| --platform, -p | Platform filter | (all) |
|
||||
| --output, -o | Output format (table, json) | table |
|
||||
|
||||
### Examples
|
||||
...
|
||||
|
||||
### Exit Codes
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | Image not found |
|
||||
| 2 | Error (auth, network, invalid input) |
|
||||
```
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint created from advisory analysis. | Project Mgmt |
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- All tasks complete -> Sprint can be marked DONE
|
||||
- Coordinate with Sprint 002_001/002 for accuracy
|
||||
@@ -0,0 +1,233 @@
|
||||
# Sprint Batch 20260113_003 - VEX Evidence Auto-Linking
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This sprint batch implements **automatic linking** between VEX exploitability status and DSSE binary-diff evidence bundles. When a binary analysis determines a vulnerability is "not_affected" due to a vendor backport, the system automatically links the VEX assertion to the cryptographic evidence that proves the claim.
|
||||
|
||||
**Scope:** VEX-to-evidence linking for binary-diff attestations
|
||||
**Effort Estimate:** 3-4 story points across 2 sprints
|
||||
**Priority:** Medium (completes evidence chain)
|
||||
|
||||
## Background
|
||||
|
||||
### Advisory Requirements
|
||||
|
||||
The original advisory specified:
|
||||
|
||||
> Surface exploitability conclusions via CycloneDX VEX (e.g., "CVE-X.Y not affected due to backported fix; evidence -> DSSE bundle link").
|
||||
|
||||
> For each CVE in SBOM components, attach exploitability status with `analysis.justification` ("component_not_present", "vulnerable_code_not_in_execute_path", "fixed", etc.) and `analysis.detail` linking the DSSE evidence URI.
|
||||
|
||||
### Existing Capabilities
|
||||
|
||||
| Component | Status | Location |
|
||||
|-----------|--------|----------|
|
||||
| `VexPredicate` | EXISTS | `src/Attestor/__Libraries/.../Predicates/VexPredicate.cs` |
|
||||
| `VexDeltaEntity` | EXISTS | `src/Excititor/__Libraries/.../Observations/VexDeltaModels.cs` |
|
||||
| `CycloneDxExporter` | EXISTS | `src/Excititor/__Libraries/.../CycloneDxExporter.cs` |
|
||||
| `BinaryDiffV1 Predicate` | IN PROGRESS | Batch 001 Sprint 002 |
|
||||
| `BinaryDiffDsseSigner` | IN PROGRESS | Batch 001 Sprint 002 |
|
||||
|
||||
### Gap Analysis
|
||||
|
||||
| Capability | Status |
|
||||
|------------|--------|
|
||||
| Store DSSE bundle URIs with VEX assertions | MISSING |
|
||||
| Auto-link binary-diff evidence to VEX | MISSING |
|
||||
| Emit `analysis.detail` with evidence URI in CycloneDX VEX | MISSING |
|
||||
| CLI `stella vex gen` with evidence links | PARTIAL |
|
||||
|
||||
## Sprint Index
|
||||
|
||||
| Sprint | ID | Module | Topic | Status | Owner |
|
||||
|--------|-----|--------|-------|--------|-------|
|
||||
| 1 | SPRINT_20260113_003_001 | EXCITITOR | VEX Evidence Linker Service | TODO | Guild - Excititor |
|
||||
| 2 | SPRINT_20260113_003_002 | CLI | VEX Generation with Evidence Links | TODO | Guild - CLI |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
+-----------------------------------------------------------------------+
|
||||
| Dependency Graph |
|
||||
+-----------------------------------------------------------------------+
|
||||
| |
|
||||
| Batch 001 (Binary Diff Attestation) |
|
||||
| | |
|
||||
| v |
|
||||
| Sprint 1 (VEX Evidence Linker) |
|
||||
| | |
|
||||
| v |
|
||||
| Sprint 2 (CLI Integration) |
|
||||
| |
|
||||
+-----------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
**Cross-Batch Dependencies:**
|
||||
- Batch 001 Sprint 002 (BinaryDiffV1 predicate) must be complete
|
||||
- VEX Evidence Linker consumes DSSE bundle URIs from binary diff
|
||||
|
||||
## Acceptance Criteria (Batch-Level)
|
||||
|
||||
### Must Have
|
||||
|
||||
1. **Evidence URI Storage**
|
||||
- Store DSSE bundle URIs alongside VEX assertions
|
||||
- Support multiple evidence sources per VEX entry
|
||||
- URIs point to OCI artifact digests or CAS addresses
|
||||
|
||||
2. **Auto-Link on Binary Diff**
|
||||
- When binary diff detects "patched" verdict, create VEX link
|
||||
- Link includes: DSSE envelope digest, predicate type, confidence score
|
||||
- Justification auto-set to "vulnerable_code_not_in_execute_path" or "code_not_reachable"
|
||||
|
||||
3. **CycloneDX VEX Output**
|
||||
- `analysis.detail` contains evidence URI
|
||||
- `analysis.response` includes evidence metadata
|
||||
- Compatible with CycloneDX VEX 1.5+ schema
|
||||
|
||||
4. **CLI Integration**
|
||||
- `stella vex gen` includes `--link-evidence` flag
|
||||
- JSON output contains evidence links
|
||||
- Human-readable output shows evidence summary
|
||||
|
||||
### Should Have
|
||||
|
||||
- Confidence threshold filtering (only link if confidence >= X)
|
||||
- Evidence chain validation (verify DSSE before linking)
|
||||
|
||||
### Deferred (Out of Scope)
|
||||
|
||||
- UI for evidence visualization (follow-up sprint)
|
||||
- Evidence refresh/update workflow
|
||||
- Third-party evidence import
|
||||
|
||||
## Technical Context
|
||||
|
||||
### Key Files to Create/Extend
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| Evidence Linker | `src/Excititor/__Libraries/StellaOps.Excititor.Core/Evidence/VexEvidenceLinker.cs` | NEW: Service to link VEX -> DSSE |
|
||||
| Evidence Models | `src/Excititor/__Libraries/StellaOps.Excititor.Core/Evidence/VexEvidenceLinkModels.cs` | NEW: Link models |
|
||||
| CycloneDX Mapper | `src/Excititor/__Libraries/.../CycloneDxVexMapper.cs` | EXTEND: Add evidence links |
|
||||
| CLI Handler | `src/Cli/StellaOps.Cli/Commands/VexGenCommandGroup.cs` | EXTEND: Add evidence option |
|
||||
|
||||
### VEX with Evidence Link Schema (CycloneDX)
|
||||
|
||||
```json
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"id": "CVE-2023-12345",
|
||||
"source": { "name": "NVD" },
|
||||
"analysis": {
|
||||
"state": "not_affected",
|
||||
"justification": "code_not_reachable",
|
||||
"detail": "Binary analysis confirms vendor backport applied. Evidence: oci://registry.example.com/evidence@sha256:abc123",
|
||||
"response": ["update"],
|
||||
"firstIssued": "2026-01-13T12:00:00Z"
|
||||
},
|
||||
"affects": [
|
||||
{
|
||||
"ref": "urn:cdx:stellaops/app@1.0.0/libssl.so.3",
|
||||
"versions": [{ "version": "3.0.2", "status": "unaffected" }]
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "stellaops:evidence:type",
|
||||
"value": "binary-diff"
|
||||
},
|
||||
{
|
||||
"name": "stellaops:evidence:uri",
|
||||
"value": "oci://registry.example.com/evidence@sha256:abc123..."
|
||||
},
|
||||
{
|
||||
"name": "stellaops:evidence:confidence",
|
||||
"value": "0.95"
|
||||
},
|
||||
{
|
||||
"name": "stellaops:evidence:predicate-type",
|
||||
"value": "stellaops.binarydiff.v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Evidence Link Model
|
||||
|
||||
```csharp
|
||||
public sealed record VexEvidenceLink
|
||||
{
|
||||
/// <summary>Type of evidence (binary-diff, reachability, runtime, etc.).</summary>
|
||||
public required string EvidenceType { get; init; }
|
||||
|
||||
/// <summary>URI to the DSSE bundle (oci://, cas://, file://).</summary>
|
||||
public required string EvidenceUri { get; init; }
|
||||
|
||||
/// <summary>Digest of the DSSE envelope.</summary>
|
||||
public required string EnvelopeDigest { get; init; }
|
||||
|
||||
/// <summary>Predicate type in the DSSE envelope.</summary>
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
/// <summary>Confidence score (0.0-1.0).</summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>When the evidence was created.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Signer identity (key ID or certificate subject).</summary>
|
||||
public string? SignerIdentity { get; init; }
|
||||
|
||||
/// <summary>Rekor log index if submitted to transparency log.</summary>
|
||||
public string? RekorLogIndex { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Evidence URI format inconsistency | Medium | Medium | Define URI schema spec; validate on link |
|
||||
| Stale evidence links | Medium | Low | Include evidence timestamp; optional refresh |
|
||||
| Large evidence bundles | Low | Medium | Link to bundle, don't embed content |
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- [ ] VEX output includes evidence links when available
|
||||
- [ ] Evidence URIs resolve to valid DSSE bundles
|
||||
- [ ] CLI shows evidence in human-readable format
|
||||
- [ ] CycloneDX VEX validates against schema
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
Before starting implementation, reviewers must read:
|
||||
|
||||
- `docs/README.md`
|
||||
- `docs/ARCHITECTURE_REFERENCE.md`
|
||||
- `CLAUDE.md` Section 8 (Code Quality & Determinism Rules)
|
||||
- CycloneDX VEX specification: https://cyclonedx.org/capabilities/vex/
|
||||
- Batch 001 BinaryDiffV1 predicate schema
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint batch created from advisory analysis. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED 2026-01-13**: Evidence stored as URI references, not embedded content.
|
||||
- **APPROVED 2026-01-13**: Use CycloneDX `properties[]` for Stella-specific evidence metadata.
|
||||
- **RISK**: CycloneDX `analysis.detail` has length limits; use URI not full content.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Batch 001 Sprint 002 complete -> Sprint 1 can start
|
||||
- Sprint 1 complete -> Sprint 2 can start
|
||||
- All sprints complete -> Integration testing checkpoint
|
||||
@@ -0,0 +1,377 @@
|
||||
# Sprint 20260113_003_001_EXCITITOR - VEX Evidence Linker Service
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
- Implement VEX-to-evidence linking service
|
||||
- Auto-link binary-diff attestations to VEX assertions
|
||||
- Store evidence URIs alongside VEX entries
|
||||
- Emit evidence metadata in CycloneDX VEX output
|
||||
- **Working directory:** `src/Excititor/__Libraries/StellaOps.Excititor.Core/`
|
||||
|
||||
## Dependencies & Concurrency
|
||||
|
||||
- **Depends on:** Batch 001 Sprint 002 (BinaryDiffV1 predicate)
|
||||
- Parallel work safe within Excititor module
|
||||
- Sprint 2 (CLI) depends on this sprint
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
- `docs/README.md`
|
||||
- `CLAUDE.md` Section 8 (Determinism Rules)
|
||||
- CycloneDX VEX specification: https://cyclonedx.org/capabilities/vex/
|
||||
- Batch 001 BinaryDiffV1 predicate schema
|
||||
- Existing VEX models in `src/Excititor/__Libraries/.../VexDeltaModels.cs`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
|---|---------|--------|---------------------------|--------|-----------------|
|
||||
| 1 | VEX-LINK-MODELS-0001 | TODO | None | Guild - Excititor | Define `VexEvidenceLink`, `VexEvidenceLinkSet`, and `EvidenceType` enum in `Evidence/VexEvidenceLinkModels.cs`. Include URI, digest, predicate type, confidence, timestamps. |
|
||||
| 2 | VEX-LINK-INTERFACE-0001 | TODO | Depends on MODELS-0001 | Guild - Excititor | Define `IVexEvidenceLinker` interface with `LinkAsync(vexEntry, evidenceSource, ct)` and `GetLinksAsync(vexEntryId, ct)` methods. |
|
||||
| 3 | VEX-LINK-BINARYDIFF-0001 | TODO | Depends on INTERFACE-0001, Batch 001 | Guild - Excititor | Implement `BinaryDiffEvidenceLinker` that extracts evidence from `BinaryDiffPredicate` findings and creates `VexEvidenceLink` entries. |
|
||||
| 4 | VEX-LINK-STORE-0001 | TODO | Depends on MODELS-0001 | Guild - Excititor | Implement `IVexEvidenceLinkStore` interface and in-memory implementation. Define PostgreSQL schema for persistent storage. |
|
||||
| 5 | VEX-LINK-AUTOLINK-0001 | TODO | Depends on BINARYDIFF-0001 | Guild - Excititor | Implement auto-linking pipeline: when binary-diff produces "patched" verdict, create VEX link with appropriate justification. |
|
||||
| 6 | VEX-LINK-CYCLONEDX-0001 | TODO | Depends on AUTOLINK-0001 | Guild - Excititor | Extend `CycloneDxVexMapper` to emit `analysis.detail` with evidence URI and `properties[]` with evidence metadata. |
|
||||
| 7 | VEX-LINK-VALIDATION-0001 | TODO | Depends on all above | Guild - Excititor | Implement evidence validation: verify DSSE signature before accepting link. Optional: verify Rekor inclusion. |
|
||||
| 8 | VEX-LINK-DI-0001 | TODO | Depends on all above | Guild - Excititor | Register all services in DI. Add `IOptions<VexEvidenceLinkOptions>` for configuration (confidence threshold, validation mode). |
|
||||
| 9 | VEX-LINK-TESTS-0001 | TODO | Depends on all above | Guild - Excititor | Unit tests covering: link creation, storage, auto-linking, CycloneDX output, validation success/failure. |
|
||||
|
||||
## Technical Specification
|
||||
|
||||
### Models
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Excititor.Core.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Link between a VEX assertion and supporting evidence.
|
||||
/// </summary>
|
||||
public sealed record VexEvidenceLink
|
||||
{
|
||||
/// <summary>Unique link identifier.</summary>
|
||||
public required string LinkId { get; init; }
|
||||
|
||||
/// <summary>VEX entry this evidence supports.</summary>
|
||||
public required string VexEntryId { get; init; }
|
||||
|
||||
/// <summary>Type of evidence.</summary>
|
||||
public required EvidenceType EvidenceType { get; init; }
|
||||
|
||||
/// <summary>URI to the evidence artifact (oci://, cas://, https://).</summary>
|
||||
public required string EvidenceUri { get; init; }
|
||||
|
||||
/// <summary>Digest of the DSSE envelope (sha256:...).</summary>
|
||||
public required string EnvelopeDigest { get; init; }
|
||||
|
||||
/// <summary>Predicate type in the DSSE envelope.</summary>
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
/// <summary>Confidence score from the evidence (0.0-1.0).</summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>Justification derived from evidence.</summary>
|
||||
public required VexJustification Justification { get; init; }
|
||||
|
||||
/// <summary>When the evidence was created.</summary>
|
||||
public required DateTimeOffset EvidenceCreatedAt { get; init; }
|
||||
|
||||
/// <summary>When the link was created.</summary>
|
||||
public required DateTimeOffset LinkedAt { get; init; }
|
||||
|
||||
/// <summary>Signer identity (key ID or certificate subject).</summary>
|
||||
public string? SignerIdentity { get; init; }
|
||||
|
||||
/// <summary>Rekor log index if submitted to transparency log.</summary>
|
||||
public string? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>Whether the evidence signature was validated.</summary>
|
||||
public bool SignatureValidated { get; init; }
|
||||
|
||||
/// <summary>Additional metadata as key-value pairs.</summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; }
|
||||
= ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of evidence that can support VEX assertions.
|
||||
/// </summary>
|
||||
public enum EvidenceType
|
||||
{
|
||||
/// <summary>Binary-level diff showing patch applied.</summary>
|
||||
BinaryDiff,
|
||||
|
||||
/// <summary>Call graph analysis showing code not reachable.</summary>
|
||||
ReachabilityAnalysis,
|
||||
|
||||
/// <summary>Runtime analysis showing code not executed.</summary>
|
||||
RuntimeAnalysis,
|
||||
|
||||
/// <summary>Human attestation (manual review).</summary>
|
||||
HumanAttestation,
|
||||
|
||||
/// <summary>Vendor advisory or statement.</summary>
|
||||
VendorAdvisory,
|
||||
|
||||
/// <summary>Other/custom evidence type.</summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX justification codes (CycloneDX compatible).
|
||||
/// </summary>
|
||||
public enum VexJustification
|
||||
{
|
||||
CodeNotPresent,
|
||||
CodeNotReachable,
|
||||
RequiresConfiguration,
|
||||
RequiresDependency,
|
||||
RequiresEnvironment,
|
||||
ProtectedByCompiler,
|
||||
ProtectedAtRuntime,
|
||||
ProtectedAtPerimeter,
|
||||
ProtectedByMitigatingControl
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection of evidence links for a VEX entry.
|
||||
/// </summary>
|
||||
public sealed record VexEvidenceLinkSet
|
||||
{
|
||||
/// <summary>VEX entry ID.</summary>
|
||||
public required string VexEntryId { get; init; }
|
||||
|
||||
/// <summary>All evidence links, sorted by confidence descending.</summary>
|
||||
public required ImmutableArray<VexEvidenceLink> Links { get; init; }
|
||||
|
||||
/// <summary>Highest confidence among all links.</summary>
|
||||
public double MaxConfidence => Links.IsEmpty ? 0 : Links.Max(l => l.Confidence);
|
||||
|
||||
/// <summary>Primary link (highest confidence).</summary>
|
||||
public VexEvidenceLink? PrimaryLink => Links.IsEmpty ? null : Links[0];
|
||||
}
|
||||
```
|
||||
|
||||
### Interfaces
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Excititor.Core.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Service for linking VEX assertions to supporting evidence.
|
||||
/// </summary>
|
||||
public interface IVexEvidenceLinker
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a link between a VEX entry and evidence.
|
||||
/// </summary>
|
||||
Task<VexEvidenceLink> LinkAsync(
|
||||
string vexEntryId,
|
||||
EvidenceSource source,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all evidence links for a VEX entry.
|
||||
/// </summary>
|
||||
Task<VexEvidenceLinkSet> GetLinksAsync(
|
||||
string vexEntryId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Auto-links evidence from a binary diff result.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<VexEvidenceLink>> AutoLinkFromBinaryDiffAsync(
|
||||
BinaryDiffPredicate diff,
|
||||
string dsseEnvelopeUri,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source of evidence for linking.
|
||||
/// </summary>
|
||||
public sealed record EvidenceSource
|
||||
{
|
||||
/// <summary>Evidence type.</summary>
|
||||
public required EvidenceType Type { get; init; }
|
||||
|
||||
/// <summary>URI to the evidence artifact.</summary>
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>Digest of the artifact.</summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>Predicate type if DSSE/in-toto.</summary>
|
||||
public string? PredicateType { get; init; }
|
||||
|
||||
/// <summary>Confidence score.</summary>
|
||||
public double Confidence { get; init; } = 1.0;
|
||||
|
||||
/// <summary>DSSE envelope bytes for validation.</summary>
|
||||
public byte[]? EnvelopeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage for evidence links.
|
||||
/// </summary>
|
||||
public interface IVexEvidenceLinkStore
|
||||
{
|
||||
Task SaveAsync(VexEvidenceLink link, CancellationToken ct = default);
|
||||
Task<VexEvidenceLink?> GetAsync(string linkId, CancellationToken ct = default);
|
||||
Task<ImmutableArray<VexEvidenceLink>> GetByVexEntryAsync(string vexEntryId, CancellationToken ct = default);
|
||||
Task DeleteAsync(string linkId, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-Link Algorithm
|
||||
|
||||
```pseudo
|
||||
function AutoLinkFromBinaryDiff(diff, dsseUri):
|
||||
links = []
|
||||
|
||||
for finding in diff.findings where finding.verdict == Patched:
|
||||
// Determine affected VEX entry
|
||||
vexEntryId = LookupVexEntry(finding.path, diff.inputs.target)
|
||||
|
||||
if vexEntryId is null:
|
||||
continue // No matching VEX entry
|
||||
|
||||
// Determine justification from finding
|
||||
justification = DetermineJustification(finding)
|
||||
|
||||
// Create link
|
||||
link = VexEvidenceLink {
|
||||
linkId: GenerateId(vexEntryId, dsseUri),
|
||||
vexEntryId: vexEntryId,
|
||||
evidenceType: BinaryDiff,
|
||||
evidenceUri: dsseUri,
|
||||
envelopeDigest: ComputeDigest(diff),
|
||||
predicateType: "stellaops.binarydiff.v1",
|
||||
confidence: finding.confidence ?? 0.9,
|
||||
justification: justification,
|
||||
evidenceCreatedAt: diff.metadata.analysisTimestamp,
|
||||
linkedAt: timeProvider.GetUtcNow()
|
||||
}
|
||||
|
||||
links.append(link)
|
||||
|
||||
return links
|
||||
|
||||
function DetermineJustification(finding):
|
||||
// If .text section changed -> code was patched
|
||||
if finding.sectionDeltas.any(d => d.section == ".text" && d.status == Modified):
|
||||
return CodeNotPresent // Vulnerable code removed/replaced
|
||||
|
||||
// If only .rodata changed -> data patched
|
||||
if finding.sectionDeltas.all(d => d.section != ".text"):
|
||||
return ProtectedAtRuntime // Runtime behavior changed
|
||||
|
||||
return CodeNotReachable // Default for verified patches
|
||||
```
|
||||
|
||||
### CycloneDX Output Enhancement
|
||||
|
||||
```csharp
|
||||
// In CycloneDxVexMapper
|
||||
private void MapEvidenceLinks(VulnerabilityAnalysis analysis, VexEvidenceLinkSet links)
|
||||
{
|
||||
if (links.PrimaryLink is null) return;
|
||||
|
||||
var primary = links.PrimaryLink;
|
||||
|
||||
// Set analysis.detail with evidence URI
|
||||
analysis.Detail = $"Evidence: {primary.EvidenceUri}";
|
||||
|
||||
// Add evidence properties
|
||||
analysis.Properties ??= [];
|
||||
analysis.Properties.Add(new Property
|
||||
{
|
||||
Name = "stellaops:evidence:type",
|
||||
Value = primary.EvidenceType.ToString().ToLowerInvariant()
|
||||
});
|
||||
analysis.Properties.Add(new Property
|
||||
{
|
||||
Name = "stellaops:evidence:uri",
|
||||
Value = primary.EvidenceUri
|
||||
});
|
||||
analysis.Properties.Add(new Property
|
||||
{
|
||||
Name = "stellaops:evidence:confidence",
|
||||
Value = primary.Confidence.ToString("F2", CultureInfo.InvariantCulture)
|
||||
});
|
||||
analysis.Properties.Add(new Property
|
||||
{
|
||||
Name = "stellaops:evidence:predicate-type",
|
||||
Value = primary.PredicateType
|
||||
});
|
||||
|
||||
if (primary.RekorLogIndex is not null)
|
||||
{
|
||||
analysis.Properties.Add(new Property
|
||||
{
|
||||
Name = "stellaops:evidence:rekor-index",
|
||||
Value = primary.RekorLogIndex
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
excititor:
|
||||
evidence:
|
||||
linking:
|
||||
enabled: true
|
||||
autoLinkOnBinaryDiff: true
|
||||
confidenceThreshold: 0.8
|
||||
validateSignatures: true
|
||||
validateRekorInclusion: false
|
||||
maxLinksPerEntry: 10
|
||||
```
|
||||
|
||||
## Determinism Requirements
|
||||
|
||||
1. **Link ID generation**: Deterministic from vexEntryId + evidenceUri
|
||||
2. **Ordering**: Links sorted by confidence DESC, then by linkedAt ASC
|
||||
3. **Timestamps**: From injected `TimeProvider`
|
||||
4. **Confidence formatting**: Two decimal places, InvariantCulture
|
||||
|
||||
## Test Cases
|
||||
|
||||
### Unit Tests
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `Link_ValidSource_CreatesLink` | Link with valid evidence | Link created with correct fields |
|
||||
| `Link_DuplicateSource_Deduplicates` | Same source linked twice | Single link returned |
|
||||
| `AutoLink_PatchedFinding_CreatesLinks` | Binary diff with patched verdict | Links created for affected entries |
|
||||
| `AutoLink_VanillaFinding_NoLinks` | Binary diff with vanilla verdict | No links created |
|
||||
| `GetLinks_ExistingEntry_ReturnsSet` | Query by VEX entry ID | All links returned, sorted |
|
||||
| `MapCycloneDx_WithLinks_IncludesEvidence` | CycloneDX export with links | Properties contain evidence metadata |
|
||||
| `Validate_ValidSignature_Succeeds` | DSSE with valid signature | Validation passes |
|
||||
| `Validate_InvalidSignature_Rejects` | DSSE with bad signature | Validation fails, link rejected |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `EndToEnd_BinaryDiffToVex_LinksEvidence` | Full pipeline from diff to VEX | VEX output contains evidence links |
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint created from advisory analysis. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED**: Evidence stored as URIs, not embedded content.
|
||||
- **APPROVED**: Auto-link only for high-confidence findings (>= threshold).
|
||||
- **RISK**: Signature validation may fail for offline evidence; add bypass option.
|
||||
- **RISK**: VEX entry lookup requires correlation logic; may need component PURL matching.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Task 1-4 complete -> Core linking operational
|
||||
- Task 5-6 complete -> Auto-link and CycloneDX working
|
||||
- Task 9 complete -> Sprint can be marked DONE
|
||||
- Unblock Sprint 2 (CLI)
|
||||
@@ -0,0 +1,132 @@
|
||||
# Sprint 20260113_003_002_CLI - VEX Generation with Evidence Links
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
- Extend `stella vex gen` command with evidence linking
|
||||
- Add `--link-evidence` flag to include binary-diff evidence
|
||||
- Display evidence summary in human-readable output
|
||||
- Emit evidence metadata in JSON output
|
||||
- **Working directory:** `src/Cli/StellaOps.Cli/Commands/`
|
||||
|
||||
## Dependencies & Concurrency
|
||||
|
||||
- **Depends on:** Sprint 003_001 (VEX Evidence Linker)
|
||||
- Extends existing `VexGenCommandGroup.cs`
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
- `docs/README.md`
|
||||
- `CLAUDE.md` Section 8 (Determinism Rules)
|
||||
- Existing VEX CLI in `src/Cli/StellaOps.Cli/Commands/VexGenCommandGroup.cs`
|
||||
- Sprint 003_001 models and interfaces
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
|---|---------|--------|---------------------------|--------|-----------------|
|
||||
| 1 | CLI-VEX-EVIDENCE-OPT-0001 | TODO | None | Guild - CLI | Add `--link-evidence` option to `stella vex gen` command. Default: true if evidence available. |
|
||||
| 2 | CLI-VEX-EVIDENCE-HANDLER-0001 | TODO | Depends on OPT-0001, Sprint 001 | Guild - CLI | Extend VEX generation handler to call `IVexEvidenceLinker.GetLinksAsync()` and include in output. |
|
||||
| 3 | CLI-VEX-EVIDENCE-JSON-0001 | TODO | Depends on HANDLER-0001 | Guild - CLI | Emit evidence links in JSON output under `evidence` key per vulnerability. |
|
||||
| 4 | CLI-VEX-EVIDENCE-TABLE-0001 | TODO | Depends on HANDLER-0001 | Guild - CLI | Show evidence summary in table output: type, confidence, URI (truncated). |
|
||||
| 5 | CLI-VEX-EVIDENCE-TESTS-0001 | TODO | Depends on all above | Guild - CLI | Unit tests for evidence flag, output formats, missing evidence handling. |
|
||||
|
||||
## Technical Specification
|
||||
|
||||
### Command Enhancement
|
||||
|
||||
```
|
||||
stella vex gen <scan-id> [options]
|
||||
|
||||
Existing options:
|
||||
--output, -o Output format (json, table, cyclonedx)
|
||||
--format, -f VEX format (openvex, cyclonedx)
|
||||
|
||||
New options:
|
||||
--link-evidence Include evidence links in output (default: true)
|
||||
--evidence-threshold Minimum confidence for evidence (default: 0.8)
|
||||
--show-evidence-uri Show full evidence URIs (default: truncated)
|
||||
```
|
||||
|
||||
### Output Examples
|
||||
|
||||
#### Table Output with Evidence
|
||||
|
||||
```
|
||||
VEX Report for scan abc123
|
||||
|
||||
+----------------+-------------+----------------+------------+------------------+
|
||||
| CVE | Component | Status | Confidence | Evidence |
|
||||
+----------------+-------------+----------------+------------+------------------+
|
||||
| CVE-2023-12345 | libssl.so.3 | not_affected | 0.95 | binary-diff [OK] |
|
||||
| CVE-2023-67890 | libcrypto | affected | - | (none) |
|
||||
| CVE-2024-11111 | nginx | not_affected | 0.88 | reachability |
|
||||
+----------------+-------------+----------------+------------+------------------+
|
||||
|
||||
Evidence Details:
|
||||
CVE-2023-12345: oci://registry/evidence@sha256:abc123...
|
||||
Type: binary-diff, Predicate: stellaops.binarydiff.v1
|
||||
Signer: CN=StellaOps Signing Key
|
||||
```
|
||||
|
||||
#### JSON Output with Evidence
|
||||
|
||||
```json
|
||||
{
|
||||
"scanId": "abc123",
|
||||
"generatedAt": "2026-01-13T12:00:00Z",
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"id": "CVE-2023-12345",
|
||||
"component": "libssl.so.3",
|
||||
"status": "not_affected",
|
||||
"justification": "code_not_present",
|
||||
"evidence": {
|
||||
"type": "binary-diff",
|
||||
"uri": "oci://registry/evidence@sha256:abc123...",
|
||||
"confidence": 0.95,
|
||||
"predicateType": "stellaops.binarydiff.v1",
|
||||
"validatedSignature": true,
|
||||
"rekorIndex": "12345678"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
```csharp
|
||||
// Extend HandleVexGenAsync
|
||||
if (linkEvidence)
|
||||
{
|
||||
var linker = services.GetRequiredService<IVexEvidenceLinker>();
|
||||
foreach (var entry in vexEntries)
|
||||
{
|
||||
var links = await linker.GetLinksAsync(entry.Id, ct);
|
||||
if (links.PrimaryLink is not null && links.MaxConfidence >= evidenceThreshold)
|
||||
{
|
||||
entry.Evidence = links.PrimaryLink;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Cases
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `VexGen_WithEvidence_IncludesLinks` | Evidence available | Links in output |
|
||||
| `VexGen_NoEvidence_OmitsField` | No evidence | `evidence: null` |
|
||||
| `VexGen_BelowThreshold_Filtered` | Low confidence evidence | Evidence omitted |
|
||||
| `VexGen_TableFormat_ShowsSummary` | Table output | Evidence column populated |
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint created from advisory analysis. | Project Mgmt |
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- All tasks complete -> Sprint can be marked DONE
|
||||
- Batch 003 complete -> Evidence chain operational
|
||||
@@ -0,0 +1,273 @@
|
||||
# Sprint Batch 20260113_004 - Golden Pairs Pilot (Vendor Backport Corpus)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This sprint batch implements a **curated dataset infrastructure** for binary patch verification. "Golden pairs" are matched sets of stock (upstream) vs vendor-patched binaries tied to specific CVEs, enabling validation of the binary diff system's ability to detect vendor backports.
|
||||
|
||||
**Scope:** Pilot corpus with 3 CVEs (Dirty Pipe, sudo Baron Samedit, PrintNightmare)
|
||||
**Effort Estimate:** 5-6 story points across 3 sprints
|
||||
**Priority:** Medium (validation infrastructure)
|
||||
|
||||
## Background
|
||||
|
||||
### Advisory Requirements
|
||||
|
||||
The original advisory specified:
|
||||
|
||||
> A curated dataset of **stock vs vendor-patched binaries** tied to authoritative **CVE + patch evidence** lets Stella Ops prove (with bytes) that a fix is present, powering deterministic VEX and "evidence-first" decisions.
|
||||
|
||||
> **Starter CVEs (tiny pilot):**
|
||||
> - **Linux:** Dirty Pipe (CVE-2022-0847) - kernel backport showcase
|
||||
> - **Unix userland:** sudo "Baron Samedit" (CVE-2021-3156) - classic multi-distro patch
|
||||
> - **Windows:** PrintNightmare (CVE-2021-34527) - PE + KB workflow
|
||||
|
||||
### Why Golden Pairs Matter
|
||||
|
||||
1. **Validation**: Ground truth for testing binary diff accuracy
|
||||
2. **Regression Testing**: Detect if changes break patch detection
|
||||
3. **Precision Metrics**: Measure actual false positive/negative rates
|
||||
4. **Documentation**: Examples of vendor backport patterns
|
||||
|
||||
### Existing Capabilities
|
||||
|
||||
| Component | Status | Location |
|
||||
|-----------|--------|----------|
|
||||
| ELF Section Hash Extractor | IN PROGRESS | Batch 001 Sprint 001 |
|
||||
| BinaryDiffV1 Predicate | IN PROGRESS | Batch 001 Sprint 002 |
|
||||
| Function Fingerprinting | EXISTS | `src/BinaryIndex/__Libraries/.../FingerprintModels.cs` |
|
||||
| Build-ID Index | EXISTS | `src/Scanner/.../Index/OfflineBuildIdIndex.cs` |
|
||||
|
||||
### Gap Analysis
|
||||
|
||||
| Capability | Status |
|
||||
|------------|--------|
|
||||
| Golden pairs data model | MISSING |
|
||||
| Package mirror scripts | MISSING |
|
||||
| Diff pipeline for corpus | MISSING |
|
||||
| Validation harness | MISSING |
|
||||
|
||||
## Sprint Index
|
||||
|
||||
| Sprint | ID | Module | Topic | Status | Owner |
|
||||
|--------|-----|--------|-------|--------|-------|
|
||||
| 1 | SPRINT_20260113_004_001 | TOOLS | Golden Pairs Data Model & Schema | TODO | Guild - Tools |
|
||||
| 2 | SPRINT_20260113_004_002 | TOOLS | Mirror & Diff Pipeline | TODO | Guild - Tools |
|
||||
| 3 | SPRINT_20260113_004_003 | TOOLS | Pilot CVE Corpus (3 CVEs) | TODO | Guild - Tools |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
+-----------------------------------------------------------------------+
|
||||
| Dependency Graph |
|
||||
+-----------------------------------------------------------------------+
|
||||
| |
|
||||
| Batch 001 (ELF Section Hashes) |
|
||||
| | |
|
||||
| v |
|
||||
| Sprint 1 (Data Model) |
|
||||
| | |
|
||||
| v |
|
||||
| Sprint 2 (Mirror & Diff Pipeline) |
|
||||
| | |
|
||||
| v |
|
||||
| Sprint 3 (Pilot Corpus) |
|
||||
| |
|
||||
+-----------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
**Cross-Batch Dependencies:**
|
||||
- Batch 001 Sprint 001 (ELF Section Hashes) should be complete for validation
|
||||
- Pipeline uses section hashes for diff validation
|
||||
|
||||
## Acceptance Criteria (Batch-Level)
|
||||
|
||||
### Must Have
|
||||
|
||||
1. **Data Model**
|
||||
- Schema for golden pair metadata (CVE, package, distro, versions)
|
||||
- Support for ELF (Linux) and PE (Windows) binaries
|
||||
- Storage for original + patched binaries with hashes
|
||||
- Links to vendor advisories and patch commits
|
||||
|
||||
2. **Mirror Scripts**
|
||||
- Fetch pre-patch and post-patch package versions
|
||||
- Support Debian/Ubuntu apt repos
|
||||
- Hash verification on download
|
||||
- Deterministic mirroring (reproducible)
|
||||
|
||||
3. **Diff Pipeline**
|
||||
- Run section hash extraction on pairs
|
||||
- Produce comparison JSON report
|
||||
- Compute match/mismatch metrics
|
||||
- Validate against expected outcomes
|
||||
|
||||
4. **Pilot Corpus (3 CVEs)**
|
||||
- CVE-2022-0847 (Dirty Pipe): Linux kernel pair
|
||||
- CVE-2021-3156 (Baron Samedit): sudo binary pair
|
||||
- CVE-2021-34527 (PrintNightmare): Windows spoolsv.dll pair (if PE ready)
|
||||
|
||||
### Should Have
|
||||
|
||||
- Debug symbol extraction (dbgsym packages)
|
||||
- Function-level diff report
|
||||
- CI integration for regression testing
|
||||
|
||||
### Deferred (Out of Scope)
|
||||
|
||||
- Ghidra/Diaphora integration (separate sprint)
|
||||
- Full multi-distro coverage
|
||||
- Automated corpus updates
|
||||
|
||||
## Technical Context
|
||||
|
||||
### Repository Layout
|
||||
|
||||
```
|
||||
src/Tools/GoldenPairs/
|
||||
+-- StellaOps.Tools.GoldenPairs/
|
||||
| +-- Models/
|
||||
| | +-- GoldenPairMetadata.cs
|
||||
| | +-- BinaryArtifact.cs
|
||||
| | +-- DiffReport.cs
|
||||
| +-- Services/
|
||||
| | +-- PackageMirrorService.cs
|
||||
| | +-- DiffPipelineService.cs
|
||||
| | +-- ValidationService.cs
|
||||
| +-- Program.cs
|
||||
+-- __Tests/
|
||||
+-- StellaOps.Tools.GoldenPairs.Tests/
|
||||
|
||||
datasets/golden-pairs/
|
||||
+-- CVE-2022-0847/
|
||||
| +-- metadata.json
|
||||
| +-- original/
|
||||
| | +-- vmlinux-5.16.11
|
||||
| | +-- vmlinux-5.16.11.sha256
|
||||
| +-- patched/
|
||||
| | +-- vmlinux-5.16.12
|
||||
| | +-- vmlinux-5.16.12.sha256
|
||||
| +-- diff-report.json
|
||||
| +-- golden-diff.json (expected outcomes)
|
||||
| +-- advisories/
|
||||
| +-- ubuntu-usn-####.md
|
||||
| +-- kernel-commit.txt
|
||||
+-- CVE-2021-3156/
|
||||
| +-- ...
|
||||
+-- index.json (corpus manifest)
|
||||
+-- README.md
|
||||
```
|
||||
|
||||
### Metadata Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://stellaops.io/schemas/golden-pair-v1.schema.json",
|
||||
"cve": "CVE-2022-0847",
|
||||
"name": "Dirty Pipe",
|
||||
"description": "Linux kernel pipe buffer flag handling vulnerability",
|
||||
"severity": "high",
|
||||
"artifact": {
|
||||
"name": "vmlinux",
|
||||
"format": "elf",
|
||||
"architecture": "x86_64"
|
||||
},
|
||||
"original": {
|
||||
"package": "linux-image-5.16.11-generic",
|
||||
"version": "5.16.11",
|
||||
"distro": "Ubuntu 22.04",
|
||||
"source": "apt://archive.ubuntu.com/ubuntu",
|
||||
"sha256": "abc123...",
|
||||
"buildId": "def456..."
|
||||
},
|
||||
"patched": {
|
||||
"package": "linux-image-5.16.12-generic",
|
||||
"version": "5.16.12",
|
||||
"distro": "Ubuntu 22.04",
|
||||
"source": "apt://archive.ubuntu.com/ubuntu",
|
||||
"sha256": "ghi789...",
|
||||
"buildId": "jkl012..."
|
||||
},
|
||||
"patch": {
|
||||
"commit": "9d2231c5d74e13b2a0546fee6737ee4446017903",
|
||||
"upstream": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=...",
|
||||
"functions_changed": ["copy_page_to_iter_pipe", "push_pipe"]
|
||||
},
|
||||
"advisories": [
|
||||
{"source": "ubuntu", "id": "USN-5317-1", "url": "https://ubuntu.com/security/notices/USN-5317-1"},
|
||||
{"source": "nvd", "id": "CVE-2022-0847", "url": "https://nvd.nist.gov/vuln/detail/CVE-2022-0847"}
|
||||
],
|
||||
"expected_diff": {
|
||||
"sections_changed": [".text"],
|
||||
"sections_identical": [".rodata", ".data"],
|
||||
"verdict": "patched",
|
||||
"confidence_min": 0.9
|
||||
},
|
||||
"created_at": "2026-01-13T12:00:00Z",
|
||||
"created_by": "StellaOps Golden Pairs Tool v1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
### Diff Report Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"cve": "CVE-2022-0847",
|
||||
"original": {"sha256": "...", "buildId": "..."},
|
||||
"patched": {"sha256": "...", "buildId": "..."},
|
||||
"sections": [
|
||||
{"name": ".text", "status": "modified", "original_hash": "...", "patched_hash": "...", "size_delta": 1024},
|
||||
{"name": ".rodata", "status": "identical", "hash": "..."},
|
||||
{"name": ".data", "status": "identical", "hash": "..."}
|
||||
],
|
||||
"verdict": "patched",
|
||||
"confidence": 0.95,
|
||||
"matches_expected": true,
|
||||
"analyzed_at": "2026-01-13T12:00:00Z",
|
||||
"tool_version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Package availability | Medium | High | Cache packages locally; document alternatives |
|
||||
| Kernel binary size | Medium | Medium | Extract specific objects, not full vmlinux |
|
||||
| Windows PE complexity | High | Medium | Defer PrintNightmare if PE support not ready |
|
||||
| Hash instability | Low | Medium | Pin to specific package versions |
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- [ ] 3 CVE pairs with complete metadata
|
||||
- [ ] Mirror scripts fetch correct versions
|
||||
- [ ] Diff pipeline produces expected verdicts
|
||||
- [ ] CI regression test passes
|
||||
- [ ] Documentation complete
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
Before starting implementation, reviewers must read:
|
||||
|
||||
- `docs/README.md`
|
||||
- `CLAUDE.md` Section 8 (Code Quality & Determinism Rules)
|
||||
- Batch 001 ELF section hash schema
|
||||
- ELF specification for section analysis
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint batch created from advisory analysis. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED 2026-01-13**: Pilot with 3 CVEs; expand corpus in follow-up sprint.
|
||||
- **APPROVED 2026-01-13**: Focus on ELF first; PE support conditional on Batch 001 progress.
|
||||
- **APPROVED 2026-01-13**: Store binaries in datasets/, not in git LFS initially.
|
||||
- **RISK**: Kernel binaries are large; consider extracting specific .ko modules instead.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Sprint 1 complete -> Data model ready for population
|
||||
- Sprint 2 complete -> Pipeline can process pairs
|
||||
- Sprint 3 complete -> Pilot corpus validated, CI integrated
|
||||
@@ -0,0 +1,346 @@
|
||||
# Sprint 20260113_004_001_TOOLS - Golden Pairs Data Model & Schema
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
- Define data model for golden pair metadata
|
||||
- Create JSON schema for validation
|
||||
- Implement C# models for tooling
|
||||
- Design storage structure for artifacts
|
||||
- **Working directory:** `src/Tools/GoldenPairs/`
|
||||
|
||||
## Dependencies & Concurrency
|
||||
|
||||
- No blocking dependencies (foundational sprint)
|
||||
- Sprint 2 (Pipeline) depends on this sprint's models
|
||||
- Can proceed in parallel with Batch 001
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
- `docs/README.md`
|
||||
- `CLAUDE.md` Section 8 (Determinism Rules)
|
||||
- ELF section types and flags
|
||||
- PE section characteristics
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
|---|---------|--------|---------------------------|--------|-----------------|
|
||||
| 1 | GP-MODEL-METADATA-0001 | TODO | None | Guild - Tools | Define `GoldenPairMetadata` record with CVE, artifact, original/patched refs, patch info, advisories, expected diff. |
|
||||
| 2 | GP-MODEL-ARTIFACT-0001 | TODO | None | Guild - Tools | Define `BinaryArtifact` record with package, version, distro, source, hashes, buildId, symbols availability. |
|
||||
| 3 | GP-MODEL-DIFF-0001 | TODO | None | Guild - Tools | Define `GoldenDiffReport` record with section comparison, verdict, confidence, tool version. |
|
||||
| 4 | GP-SCHEMA-JSON-0001 | TODO | Depends on MODEL-* | Guild - Tools | Create JSON Schema `golden-pair-v1.schema.json` for metadata validation. Publish to `docs/schemas/`. |
|
||||
| 5 | GP-SCHEMA-INDEX-0001 | TODO | Depends on SCHEMA-JSON | Guild - Tools | Create corpus index schema `golden-pairs-index.schema.json` for dataset manifest. |
|
||||
| 6 | GP-STORAGE-LAYOUT-0001 | TODO | Depends on MODEL-* | Guild - Tools | Document storage layout in `datasets/golden-pairs/README.md`. Include artifact naming conventions. |
|
||||
| 7 | GP-MODEL-LOADER-0001 | TODO | Depends on all models | Guild - Tools | Implement `GoldenPairLoader` service to read/validate metadata from filesystem. |
|
||||
| 8 | GP-MODEL-TESTS-0001 | TODO | Depends on all above | Guild - Tools | Unit tests for model serialization, schema validation, loader functionality. |
|
||||
|
||||
## Technical Specification
|
||||
|
||||
### Core Models
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Tools.GoldenPairs.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for a golden pair (stock vs patched binary).
|
||||
/// </summary>
|
||||
public sealed record GoldenPairMetadata
|
||||
{
|
||||
/// <summary>CVE identifier (e.g., "CVE-2022-0847").</summary>
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>Human-readable vulnerability name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Brief description of the vulnerability.</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Severity level (critical, high, medium, low).</summary>
|
||||
public required string Severity { get; init; }
|
||||
|
||||
/// <summary>Target artifact information.</summary>
|
||||
public required ArtifactInfo Artifact { get; init; }
|
||||
|
||||
/// <summary>Original (unpatched) binary.</summary>
|
||||
public required BinaryArtifact Original { get; init; }
|
||||
|
||||
/// <summary>Patched binary.</summary>
|
||||
public required BinaryArtifact Patched { get; init; }
|
||||
|
||||
/// <summary>Patch commit/change information.</summary>
|
||||
public required PatchInfo Patch { get; init; }
|
||||
|
||||
/// <summary>Security advisories for this CVE.</summary>
|
||||
public ImmutableArray<AdvisoryRef> Advisories { get; init; } = [];
|
||||
|
||||
/// <summary>Expected diff results for validation.</summary>
|
||||
public required ExpectedDiff ExpectedDiff { get; init; }
|
||||
|
||||
/// <summary>When this pair was created.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Tool version that created this pair.</summary>
|
||||
public required string CreatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the target artifact.
|
||||
/// </summary>
|
||||
public sealed record ArtifactInfo
|
||||
{
|
||||
/// <summary>Artifact name (e.g., "vmlinux", "sudo", "spoolsv.dll").</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Binary format (elf, pe, macho).</summary>
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>CPU architecture (x86_64, aarch64, etc.).</summary>
|
||||
public required string Architecture { get; init; }
|
||||
|
||||
/// <summary>Operating system (linux, windows, darwin).</summary>
|
||||
public string Os { get; init; } = "linux";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A binary artifact in the golden pair.
|
||||
/// </summary>
|
||||
public sealed record BinaryArtifact
|
||||
{
|
||||
/// <summary>Package name (e.g., "linux-image-5.16.11-generic").</summary>
|
||||
public required string Package { get; init; }
|
||||
|
||||
/// <summary>Package version.</summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>Distribution (e.g., "Ubuntu 22.04", "Debian 11").</summary>
|
||||
public required string Distro { get; init; }
|
||||
|
||||
/// <summary>Package source (apt://, https://, file://).</summary>
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of the binary.</summary>
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
/// <summary>ELF Build-ID or PE GUID (if available).</summary>
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>Debug symbols available.</summary>
|
||||
public bool HasDebugSymbols { get; init; }
|
||||
|
||||
/// <summary>Path to debug symbols package.</summary>
|
||||
public string? DebugSymbolsSource { get; init; }
|
||||
|
||||
/// <summary>Relative path within the package.</summary>
|
||||
public string? PathInPackage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the security patch.
|
||||
/// </summary>
|
||||
public sealed record PatchInfo
|
||||
{
|
||||
/// <summary>Commit hash of the fix.</summary>
|
||||
public required string Commit { get; init; }
|
||||
|
||||
/// <summary>URL to upstream commit.</summary>
|
||||
public string? Upstream { get; init; }
|
||||
|
||||
/// <summary>Functions changed by the patch.</summary>
|
||||
public ImmutableArray<string> FunctionsChanged { get; init; } = [];
|
||||
|
||||
/// <summary>Files changed by the patch.</summary>
|
||||
public ImmutableArray<string> FilesChanged { get; init; } = [];
|
||||
|
||||
/// <summary>Patch summary.</summary>
|
||||
public string? Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a security advisory.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryRef
|
||||
{
|
||||
/// <summary>Advisory source (ubuntu, debian, nvd, msrc, etc.).</summary>
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>Advisory identifier (e.g., "USN-5317-1").</summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>URL to the advisory.</summary>
|
||||
public required string Url { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expected diff results for validation.
|
||||
/// </summary>
|
||||
public sealed record ExpectedDiff
|
||||
{
|
||||
/// <summary>Sections expected to be modified.</summary>
|
||||
public ImmutableArray<string> SectionsChanged { get; init; } = [];
|
||||
|
||||
/// <summary>Sections expected to be identical.</summary>
|
||||
public ImmutableArray<string> SectionsIdentical { get; init; } = [];
|
||||
|
||||
/// <summary>Expected verdict (patched, vanilla, unknown).</summary>
|
||||
public required string Verdict { get; init; }
|
||||
|
||||
/// <summary>Minimum confidence score expected.</summary>
|
||||
public double ConfidenceMin { get; init; } = 0.9;
|
||||
}
|
||||
```
|
||||
|
||||
### Diff Report Model
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Report from comparing a golden pair.
|
||||
/// </summary>
|
||||
public sealed record GoldenDiffReport
|
||||
{
|
||||
/// <summary>CVE being analyzed.</summary>
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>Original binary info.</summary>
|
||||
public required ArtifactHashInfo Original { get; init; }
|
||||
|
||||
/// <summary>Patched binary info.</summary>
|
||||
public required ArtifactHashInfo Patched { get; init; }
|
||||
|
||||
/// <summary>Section-by-section comparison.</summary>
|
||||
public required ImmutableArray<SectionComparison> Sections { get; init; }
|
||||
|
||||
/// <summary>Overall verdict.</summary>
|
||||
public required string Verdict { get; init; }
|
||||
|
||||
/// <summary>Confidence score (0.0-1.0).</summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>Whether result matches expected.</summary>
|
||||
public required bool MatchesExpected { get; init; }
|
||||
|
||||
/// <summary>Discrepancies from expected (if any).</summary>
|
||||
public ImmutableArray<string> Discrepancies { get; init; } = [];
|
||||
|
||||
/// <summary>Analysis timestamp.</summary>
|
||||
public required DateTimeOffset AnalyzedAt { get; init; }
|
||||
|
||||
/// <summary>Tool version.</summary>
|
||||
public required string ToolVersion { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ArtifactHashInfo
|
||||
{
|
||||
public required string Sha256 { get; init; }
|
||||
public string? BuildId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SectionComparison
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Status { get; init; } // identical, modified, added, removed
|
||||
public string? OriginalHash { get; init; }
|
||||
public string? PatchedHash { get; init; }
|
||||
public long? SizeDelta { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### JSON Schema (Excerpt)
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://stellaops.io/schemas/golden-pair-v1.schema.json",
|
||||
"title": "GoldenPairMetadata",
|
||||
"type": "object",
|
||||
"required": ["cve", "name", "severity", "artifact", "original", "patched", "patch", "expectedDiff", "createdAt", "createdBy"],
|
||||
"properties": {
|
||||
"cve": {
|
||||
"type": "string",
|
||||
"pattern": "^CVE-\\d{4}-\\d{4,}$"
|
||||
},
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"severity": { "enum": ["critical", "high", "medium", "low"] },
|
||||
"artifact": { "$ref": "#/$defs/ArtifactInfo" },
|
||||
"original": { "$ref": "#/$defs/BinaryArtifact" },
|
||||
"patched": { "$ref": "#/$defs/BinaryArtifact" },
|
||||
"patch": { "$ref": "#/$defs/PatchInfo" },
|
||||
"advisories": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/AdvisoryRef" }
|
||||
},
|
||||
"expectedDiff": { "$ref": "#/$defs/ExpectedDiff" },
|
||||
"createdAt": { "type": "string", "format": "date-time" },
|
||||
"createdBy": { "type": "string" }
|
||||
},
|
||||
"$defs": {
|
||||
"ArtifactInfo": {
|
||||
"type": "object",
|
||||
"required": ["name", "format", "architecture"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"format": { "enum": ["elf", "pe", "macho"] },
|
||||
"architecture": { "type": "string" }
|
||||
}
|
||||
}
|
||||
// ... additional definitions
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Storage Layout
|
||||
|
||||
```
|
||||
datasets/golden-pairs/
|
||||
+-- index.json # Corpus manifest
|
||||
+-- README.md # Documentation
|
||||
+-- CVE-2022-0847/
|
||||
| +-- metadata.json # GoldenPairMetadata
|
||||
| +-- original/
|
||||
| | +-- vmlinux # Unpatched binary
|
||||
| | +-- vmlinux.sha256 # Hash file
|
||||
| | +-- vmlinux.sections.json # Pre-computed section hashes
|
||||
| +-- patched/
|
||||
| | +-- vmlinux # Patched binary
|
||||
| | +-- vmlinux.sha256
|
||||
| | +-- vmlinux.sections.json
|
||||
| +-- diff-report.json # Comparison output
|
||||
| +-- advisories/
|
||||
| +-- USN-5317-1.txt # Advisory text
|
||||
+-- CVE-2021-3156/
|
||||
+-- ...
|
||||
```
|
||||
|
||||
## Determinism Requirements
|
||||
|
||||
1. **Hashes**: SHA-256 lowercase hex, no prefix
|
||||
2. **Timestamps**: UTC ISO-8601
|
||||
3. **Ordering**: Sections sorted by name; advisories sorted by source+id
|
||||
4. **JSON**: Canonical formatting (sorted keys, 2-space indent)
|
||||
|
||||
## Test Cases
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `Serialize_RoundTrip_Identical` | Serialize then deserialize | Identical metadata |
|
||||
| `Validate_ValidSchema_Passes` | Valid JSON against schema | Validation passes |
|
||||
| `Validate_MissingCve_Fails` | Missing required field | Validation fails |
|
||||
| `Load_ExistingPair_ReturnsMetadata` | Load from filesystem | Correct metadata |
|
||||
| `Load_MissingFiles_ReturnsError` | Missing artifact files | Error with details |
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint created from advisory analysis. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED**: Store binaries outside git, reference by hash.
|
||||
- **APPROVED**: Pre-compute section hashes for faster diff pipeline.
|
||||
- **RISK**: Large binaries may exceed storage limits; use compression.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Task 1-3 complete -> Core models ready
|
||||
- Task 4-6 complete -> Schema and storage documented
|
||||
- Task 7-8 complete -> Sprint can be marked DONE
|
||||
@@ -0,0 +1,330 @@
|
||||
# Sprint 20260113_004_002_TOOLS - Mirror & Diff Pipeline
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
- Implement package mirror service for Debian/Ubuntu
|
||||
- Create diff pipeline service for golden pair validation
|
||||
- Build validation harness for expected outcomes
|
||||
- Support reproducible artifact fetching
|
||||
- **Working directory:** `src/Tools/GoldenPairs/`
|
||||
|
||||
## Dependencies & Concurrency
|
||||
|
||||
- **Depends on:** Sprint 004_001 (Data Model)
|
||||
- **Depends on:** Batch 001 Sprint 001 (ELF Section Hashes)
|
||||
- Sprint 3 (Pilot Corpus) depends on this sprint
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
- `docs/README.md`
|
||||
- `CLAUDE.md` Section 8 (Determinism Rules)
|
||||
- Sprint 004_001 data models
|
||||
- Batch 001 `ElfSectionHashExtractor` interface
|
||||
- Debian/Ubuntu apt repository structure
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
|---|---------|--------|---------------------------|--------|-----------------|
|
||||
| 1 | GP-MIRROR-INTERFACE-0001 | TODO | None | Guild - Tools | Define `IPackageMirrorService` interface with `FetchAsync(artifact, destination, ct)` signature. Support verification and resume. |
|
||||
| 2 | GP-MIRROR-APT-0001 | TODO | Depends on INTERFACE | Guild - Tools | Implement `AptPackageMirrorService` for Debian/Ubuntu. Parse Packages.gz, download .deb, extract target binary. |
|
||||
| 3 | GP-MIRROR-VERIFY-0001 | TODO | Depends on APT | Guild - Tools | Implement hash verification: compare downloaded SHA-256 with metadata. Fail if mismatch. |
|
||||
| 4 | GP-DIFF-INTERFACE-0001 | TODO | Sprint 001 models | Guild - Tools | Define `IDiffPipelineService` interface with `DiffAsync(pair, ct)` returning `GoldenDiffReport`. |
|
||||
| 5 | GP-DIFF-IMPL-0001 | TODO | Depends on INTERFACE, Batch 001 | Guild - Tools | Implement `DiffPipelineService` that: loads metadata, extracts section hashes, compares, produces report. |
|
||||
| 6 | GP-DIFF-VALIDATE-0001 | TODO | Depends on IMPL | Guild - Tools | Implement validation against `expectedDiff`: check sections changed/identical, verdict, confidence threshold. |
|
||||
| 7 | GP-CLI-MIRROR-0001 | TODO | Depends on MIRROR-* | Guild - Tools | Add `golden-pairs mirror <cve>` CLI command to fetch artifacts for a pair. |
|
||||
| 8 | GP-CLI-DIFF-0001 | TODO | Depends on DIFF-* | Guild - Tools | Add `golden-pairs diff <cve>` CLI command to run diff and validation. |
|
||||
| 9 | GP-CLI-VALIDATE-0001 | TODO | Depends on all above | Guild - Tools | Add `golden-pairs validate` CLI command to run all pairs and produce summary. |
|
||||
| 10 | GP-TESTS-0001 | TODO | Depends on all above | Guild - Tools | Unit and integration tests for mirror, diff, validation services. |
|
||||
|
||||
## Technical Specification
|
||||
|
||||
### Mirror Service Interface
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Tools.GoldenPairs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for mirroring package artifacts.
|
||||
/// </summary>
|
||||
public interface IPackageMirrorService
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches an artifact from its source.
|
||||
/// </summary>
|
||||
/// <param name="artifact">Artifact to fetch.</param>
|
||||
/// <param name="destination">Local destination path.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result with hash and path.</returns>
|
||||
Task<MirrorResult> FetchAsync(
|
||||
BinaryArtifact artifact,
|
||||
string destination,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a local artifact against expected hash.
|
||||
/// </summary>
|
||||
Task<bool> VerifyAsync(
|
||||
string path,
|
||||
string expectedSha256,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record MirrorResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public required string LocalPath { get; init; }
|
||||
public required string ActualSha256 { get; init; }
|
||||
public bool HashMatches { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public long BytesDownloaded { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### Apt Mirror Implementation
|
||||
|
||||
```csharp
|
||||
public class AptPackageMirrorService : IPackageMirrorService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<AptPackageMirrorService> _logger;
|
||||
|
||||
public async Task<MirrorResult> FetchAsync(
|
||||
BinaryArtifact artifact,
|
||||
string destination,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Parse source URI: apt://archive.ubuntu.com/ubuntu/pool/main/l/linux/...
|
||||
var uri = ParseAptUri(artifact.Source);
|
||||
|
||||
// Download .deb package
|
||||
var debPath = Path.Combine(destination, $"{artifact.Package}.deb");
|
||||
await DownloadWithRetryAsync(uri, debPath, ct);
|
||||
|
||||
// Extract target binary from .deb
|
||||
var binaryPath = await ExtractFromDebAsync(debPath, artifact.PathInPackage, destination, ct);
|
||||
|
||||
// Verify hash
|
||||
var actualHash = await ComputeSha256Async(binaryPath, ct);
|
||||
var hashMatches = string.Equals(actualHash, artifact.Sha256, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new MirrorResult
|
||||
{
|
||||
Success = hashMatches,
|
||||
LocalPath = binaryPath,
|
||||
ActualSha256 = actualHash,
|
||||
HashMatches = hashMatches,
|
||||
ErrorMessage = hashMatches ? null : $"Hash mismatch: expected {artifact.Sha256}, got {actualHash}"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string> ExtractFromDebAsync(
|
||||
string debPath,
|
||||
string? pathInPackage,
|
||||
string destination,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// .deb is ar archive containing data.tar.* with actual files
|
||||
// Use ar + tar to extract, or SharpCompress library
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Diff Pipeline Interface
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Pipeline for diffing golden pairs.
|
||||
/// </summary>
|
||||
public interface IDiffPipelineService
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs diff analysis on a golden pair.
|
||||
/// </summary>
|
||||
Task<GoldenDiffReport> DiffAsync(
|
||||
GoldenPairMetadata pair,
|
||||
DiffOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a diff report against expected outcomes.
|
||||
/// </summary>
|
||||
ValidationResult Validate(GoldenDiffReport report, ExpectedDiff expected);
|
||||
}
|
||||
|
||||
public sealed record DiffOptions
|
||||
{
|
||||
/// <summary>Sections to analyze (default: all).</summary>
|
||||
public ImmutableArray<string>? SectionFilter { get; init; }
|
||||
|
||||
/// <summary>Skip hash computation if pre-computed hashes exist.</summary>
|
||||
public bool UsePrecomputedHashes { get; init; } = true;
|
||||
|
||||
/// <summary>Include function-level analysis if debug symbols available.</summary>
|
||||
public bool IncludeFunctionAnalysis { get; init; } = false;
|
||||
}
|
||||
|
||||
public sealed record ValidationResult
|
||||
{
|
||||
public required bool IsValid { get; init; }
|
||||
public required ImmutableArray<string> Errors { get; init; }
|
||||
public required ImmutableArray<string> Warnings { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### Diff Pipeline Implementation
|
||||
|
||||
```csharp
|
||||
public class DiffPipelineService : IDiffPipelineService
|
||||
{
|
||||
private readonly IElfSectionHashExtractor _elfExtractor;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DiffPipelineService> _logger;
|
||||
|
||||
public async Task<GoldenDiffReport> DiffAsync(
|
||||
GoldenPairMetadata pair,
|
||||
DiffOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
options ??= new DiffOptions();
|
||||
|
||||
// Get or compute section hashes
|
||||
var originalHashes = await GetSectionHashesAsync(pair, isOriginal: true, options, ct);
|
||||
var patchedHashes = await GetSectionHashesAsync(pair, isOriginal: false, options, ct);
|
||||
|
||||
// Compare sections
|
||||
var sections = CompareSections(originalHashes, patchedHashes, options.SectionFilter);
|
||||
|
||||
// Determine verdict
|
||||
var (verdict, confidence) = DetermineVerdict(sections, pair.ExpectedDiff);
|
||||
|
||||
// Validate against expected
|
||||
var matchesExpected = ValidateAgainstExpected(sections, verdict, confidence, pair.ExpectedDiff);
|
||||
|
||||
return new GoldenDiffReport
|
||||
{
|
||||
Cve = pair.Cve,
|
||||
Original = new ArtifactHashInfo { Sha256 = pair.Original.Sha256, BuildId = pair.Original.BuildId },
|
||||
Patched = new ArtifactHashInfo { Sha256 = pair.Patched.Sha256, BuildId = pair.Patched.BuildId },
|
||||
Sections = sections,
|
||||
Verdict = verdict,
|
||||
Confidence = confidence,
|
||||
MatchesExpected = matchesExpected.IsValid,
|
||||
Discrepancies = matchesExpected.Errors,
|
||||
AnalyzedAt = _timeProvider.GetUtcNow(),
|
||||
ToolVersion = GetToolVersion()
|
||||
};
|
||||
}
|
||||
|
||||
private (string verdict, double confidence) DetermineVerdict(
|
||||
ImmutableArray<SectionComparison> sections,
|
||||
ExpectedDiff expected)
|
||||
{
|
||||
var textSection = sections.FirstOrDefault(s => s.Name == ".text");
|
||||
|
||||
if (textSection is null)
|
||||
return ("unknown", 0.5);
|
||||
|
||||
if (textSection.Status == "modified")
|
||||
{
|
||||
// .text changed -> likely patched
|
||||
var otherChanges = sections.Count(s => s.Status == "modified" && s.Name != ".text");
|
||||
var confidence = otherChanges > 2 ? 0.7 : 0.95; // Too many changes = less certain
|
||||
return ("patched", confidence);
|
||||
}
|
||||
|
||||
if (textSection.Status == "identical")
|
||||
{
|
||||
return ("vanilla", 0.9);
|
||||
}
|
||||
|
||||
return ("unknown", 0.5);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```
|
||||
golden-pairs <command>
|
||||
|
||||
Commands:
|
||||
mirror <cve> Fetch artifacts for a golden pair
|
||||
diff <cve> Run diff analysis on a golden pair
|
||||
validate Validate all golden pairs in corpus
|
||||
list List all available golden pairs
|
||||
|
||||
Examples:
|
||||
golden-pairs mirror CVE-2022-0847
|
||||
golden-pairs diff CVE-2022-0847 --output json
|
||||
golden-pairs validate --fail-fast
|
||||
```
|
||||
|
||||
### CI Integration
|
||||
|
||||
```yaml
|
||||
# .gitea/workflows/golden-pairs-validation.yml
|
||||
name: Golden Pairs Validation
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'datasets/golden-pairs/**'
|
||||
- 'src/Tools/GoldenPairs/**'
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Weekly
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
- run: dotnet build src/Tools/GoldenPairs/
|
||||
- run: dotnet run --project src/Tools/GoldenPairs/ -- validate --output trx
|
||||
- uses: dorny/test-reporter@v1
|
||||
with:
|
||||
name: Golden Pairs
|
||||
path: 'golden-pairs.trx'
|
||||
reporter: dotnet-trx
|
||||
```
|
||||
|
||||
## Determinism Requirements
|
||||
|
||||
1. **Download order**: Single-threaded to ensure reproducibility
|
||||
2. **Hash computation**: Identical algorithm as Batch 001
|
||||
3. **Timestamps**: From injected `TimeProvider`
|
||||
4. **Report ordering**: Sections sorted by name
|
||||
|
||||
## Test Cases
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `Mirror_ValidPackage_Downloads` | Download existing package | Success, hash matches |
|
||||
| `Mirror_MissingPackage_Fails` | Download non-existent package | Failure with error message |
|
||||
| `Mirror_HashMismatch_Fails` | Download with wrong hash | Failure, hash mismatch reported |
|
||||
| `Diff_ModifiedText_ReturnsPatched` | Pair with .text changed | Verdict: patched |
|
||||
| `Diff_IdenticalAll_ReturnsVanilla` | Pair with no changes | Verdict: vanilla |
|
||||
| `Validate_MatchesExpected_Passes` | Diff matches expectedDiff | IsValid: true |
|
||||
| `Validate_WrongVerdict_Fails` | Diff disagrees with expected | IsValid: false, error listed |
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint created from advisory analysis. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED**: Support apt:// sources first; add RPM later.
|
||||
- **APPROVED**: Cache downloaded packages locally to avoid re-fetch.
|
||||
- **RISK**: Apt repository structure may vary; handle exceptions gracefully.
|
||||
- **RISK**: Some packages may be removed from mirrors; document fallbacks.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Task 1-3 complete -> Mirror service operational
|
||||
- Task 4-6 complete -> Diff pipeline operational
|
||||
- Task 7-9 complete -> CLI usable
|
||||
- Task 10 complete -> Sprint can be marked DONE
|
||||
259
docs/implplan/SPRINT_20260113_004_003_TOOLS_pilot_corpus.md
Normal file
259
docs/implplan/SPRINT_20260113_004_003_TOOLS_pilot_corpus.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# Sprint 20260113_004_003_TOOLS - Pilot CVE Corpus (3 CVEs)
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
- Populate pilot corpus with 3 CVE golden pairs
|
||||
- CVE-2022-0847 (Dirty Pipe): Linux kernel
|
||||
- CVE-2021-3156 (Baron Samedit): sudo userland
|
||||
- CVE-2021-34527 (PrintNightmare): Windows PE (conditional)
|
||||
- Document each pair with advisories and patch info
|
||||
- **Working directory:** `datasets/golden-pairs/`
|
||||
|
||||
## Dependencies & Concurrency
|
||||
|
||||
- **Depends on:** Sprint 004_001 (Data Model)
|
||||
- **Depends on:** Sprint 004_002 (Pipeline)
|
||||
- **Depends on:** Batch 001 Sprint 001 (ELF Section Hashes) for validation
|
||||
- Final sprint in batch
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
- Sprint 004_001 data models
|
||||
- Sprint 004_002 pipeline services
|
||||
- Vulnerability details for each CVE
|
||||
- Package sources for target distros
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
|---|---------|--------|---------------------------|--------|-----------------|
|
||||
| 1 | GP-CORPUS-DIRTYPIPE-META-0001 | TODO | None | Guild - Tools | Create `CVE-2022-0847/metadata.json` with full golden pair metadata. Identify Ubuntu 22.04 kernel package versions. |
|
||||
| 2 | GP-CORPUS-DIRTYPIPE-FETCH-0001 | TODO | Depends on META, Sprint 002 | Guild - Tools | Fetch vmlinux binaries for pre-patch (5.16.11) and post-patch (5.16.12) versions using mirror service. |
|
||||
| 3 | GP-CORPUS-DIRTYPIPE-DIFF-0001 | TODO | Depends on FETCH | Guild - Tools | Run diff pipeline, validate .text section change, verify verdict matches expected. |
|
||||
| 4 | GP-CORPUS-DIRTYPIPE-DOCS-0001 | TODO | Depends on all above | Guild - Tools | Document advisory links, patch commit, functions changed. Archive advisory PDFs. |
|
||||
| 5 | GP-CORPUS-BARON-META-0001 | TODO | None | Guild - Tools | Create `CVE-2021-3156/metadata.json`. Identify Debian 11 sudo package versions. |
|
||||
| 6 | GP-CORPUS-BARON-FETCH-0001 | TODO | Depends on META, Sprint 002 | Guild - Tools | Fetch sudo binaries for pre-patch and post-patch versions. |
|
||||
| 7 | GP-CORPUS-BARON-DIFF-0001 | TODO | Depends on FETCH | Guild - Tools | Run diff pipeline, validate, verify verdict. |
|
||||
| 8 | GP-CORPUS-BARON-DOCS-0001 | TODO | Depends on all above | Guild - Tools | Document advisory links, patch commit. |
|
||||
| 9 | GP-CORPUS-PRINT-META-0001 | TODO (CONDITIONAL) | PE support ready | Guild - Tools | Create `CVE-2021-34527/metadata.json` if PE section hashing available. |
|
||||
| 10 | GP-CORPUS-INDEX-0001 | TODO | Depends on all pairs | Guild - Tools | Create `index.json` corpus manifest listing all pairs with summary. |
|
||||
| 11 | GP-CORPUS-README-0001 | TODO | Depends on INDEX | Guild - Tools | Create `README.md` with corpus documentation, usage instructions, extension guide. |
|
||||
| 12 | GP-CORPUS-CI-0001 | TODO | Depends on all above | Guild - Tools | Add CI workflow to validate corpus on changes. Integrate with test reporting. |
|
||||
|
||||
## Technical Specification
|
||||
|
||||
### CVE-2022-0847 (Dirty Pipe)
|
||||
|
||||
**Vulnerability:** Linux kernel pipe buffer flag handling allows privilege escalation.
|
||||
|
||||
**Target:**
|
||||
- Binary: `vmlinux` (or specific .ko module `fs/pipe.c`)
|
||||
- Architecture: x86_64
|
||||
- Format: ELF
|
||||
|
||||
**Package Sources (Ubuntu 22.04):**
|
||||
- Pre-patch: `linux-image-5.16.11-generic` from `archive.ubuntu.com`
|
||||
- Post-patch: `linux-image-5.16.12-generic`
|
||||
|
||||
**Patch Info:**
|
||||
- Commit: `9d2231c5d74e13b2a0546fee6737ee4446017903`
|
||||
- Functions: `copy_page_to_iter_pipe`, `push_pipe`
|
||||
- Files: `fs/pipe.c`, `lib/iov_iter.c`
|
||||
|
||||
**Expected Diff:**
|
||||
- `.text`: MODIFIED (vulnerability fix)
|
||||
- `.rodata`: IDENTICAL or MODIFIED (string changes)
|
||||
- Verdict: `patched`
|
||||
- Confidence: >= 0.9
|
||||
|
||||
**Advisories:**
|
||||
- USN-5317-1: https://ubuntu.com/security/notices/USN-5317-1
|
||||
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2022-0847
|
||||
|
||||
### CVE-2021-3156 (Baron Samedit)
|
||||
|
||||
**Vulnerability:** Heap-based buffer overflow in sudo sudoedit.
|
||||
|
||||
**Target:**
|
||||
- Binary: `/usr/bin/sudo`
|
||||
- Architecture: x86_64
|
||||
- Format: ELF
|
||||
|
||||
**Package Sources (Debian 11):**
|
||||
- Pre-patch: `sudo_1.9.5p2-3` from `snapshot.debian.org`
|
||||
- Post-patch: `sudo_1.9.5p2-3+deb11u1`
|
||||
|
||||
**Patch Info:**
|
||||
- Functions: `set_cmnd`, `sudoedit_setup`
|
||||
- Files: `src/sudoers.c`, `src/sudoedit.c`
|
||||
|
||||
**Expected Diff:**
|
||||
- `.text`: MODIFIED
|
||||
- Verdict: `patched`
|
||||
|
||||
**Advisories:**
|
||||
- DSA-4839-1: https://www.debian.org/security/2021/dsa-4839
|
||||
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2021-3156
|
||||
|
||||
### CVE-2021-34527 (PrintNightmare) - CONDITIONAL
|
||||
|
||||
**Vulnerability:** Windows Print Spooler remote code execution.
|
||||
|
||||
**Target:**
|
||||
- Binary: `spoolsv.dll` or `localspl.dll`
|
||||
- Architecture: x64
|
||||
- Format: PE
|
||||
|
||||
**Condition:** Only include if PE section hashing from Batch 001 is available.
|
||||
|
||||
**Package Sources:**
|
||||
- Microsoft Update Catalog KB5004945
|
||||
- Or: Extract from Windows ISO
|
||||
|
||||
**Expected Diff:**
|
||||
- `.text`: MODIFIED
|
||||
- Verdict: `patched`
|
||||
|
||||
### Metadata Template
|
||||
|
||||
```json
|
||||
{
|
||||
"cve": "CVE-2022-0847",
|
||||
"name": "Dirty Pipe",
|
||||
"description": "A flaw was found in the way the pipe buffer flag was handled in the Linux kernel. An unprivileged local user could exploit this flaw to overwrite data in arbitrary read-only files.",
|
||||
"severity": "high",
|
||||
"artifact": {
|
||||
"name": "vmlinux",
|
||||
"format": "elf",
|
||||
"architecture": "x86_64",
|
||||
"os": "linux"
|
||||
},
|
||||
"original": {
|
||||
"package": "linux-image-5.16.11-generic",
|
||||
"version": "5.16.11",
|
||||
"distro": "Ubuntu 22.04",
|
||||
"source": "apt://archive.ubuntu.com/ubuntu/pool/main/l/linux/linux-image-5.16.11-generic_5.16.11-amd64.deb",
|
||||
"sha256": "TODO_COMPUTE_AFTER_FETCH",
|
||||
"buildId": "TODO_EXTRACT_AFTER_FETCH",
|
||||
"hasDebugSymbols": false,
|
||||
"pathInPackage": "/boot/vmlinux-5.16.11-generic"
|
||||
},
|
||||
"patched": {
|
||||
"package": "linux-image-5.16.12-generic",
|
||||
"version": "5.16.12",
|
||||
"distro": "Ubuntu 22.04",
|
||||
"source": "apt://archive.ubuntu.com/ubuntu/pool/main/l/linux/linux-image-5.16.12-generic_5.16.12-amd64.deb",
|
||||
"sha256": "TODO_COMPUTE_AFTER_FETCH",
|
||||
"buildId": "TODO_EXTRACT_AFTER_FETCH",
|
||||
"hasDebugSymbols": false,
|
||||
"pathInPackage": "/boot/vmlinux-5.16.12-generic"
|
||||
},
|
||||
"patch": {
|
||||
"commit": "9d2231c5d74e13b2a0546fee6737ee4446017903",
|
||||
"upstream": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=9d2231c5d74e13b2a0546fee6737ee4446017903",
|
||||
"functionsChanged": ["copy_page_to_iter_pipe", "push_pipe"],
|
||||
"filesChanged": ["fs/pipe.c", "lib/iov_iter.c"],
|
||||
"summary": "Fix PIPE_BUF_FLAG_CAN_MERGE handling to prevent arbitrary file overwrites"
|
||||
},
|
||||
"advisories": [
|
||||
{
|
||||
"source": "ubuntu",
|
||||
"id": "USN-5317-1",
|
||||
"url": "https://ubuntu.com/security/notices/USN-5317-1"
|
||||
},
|
||||
{
|
||||
"source": "nvd",
|
||||
"id": "CVE-2022-0847",
|
||||
"url": "https://nvd.nist.gov/vuln/detail/CVE-2022-0847"
|
||||
}
|
||||
],
|
||||
"expectedDiff": {
|
||||
"sectionsChanged": [".text"],
|
||||
"sectionsIdentical": [".rodata", ".data", ".bss"],
|
||||
"verdict": "patched",
|
||||
"confidenceMin": 0.9
|
||||
},
|
||||
"createdAt": "2026-01-13T12:00:00Z",
|
||||
"createdBy": "StellaOps Golden Pairs Tool v1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
### Corpus Index
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"generatedAt": "2026-01-13T12:00:00Z",
|
||||
"pairs": [
|
||||
{
|
||||
"cve": "CVE-2022-0847",
|
||||
"name": "Dirty Pipe",
|
||||
"severity": "high",
|
||||
"format": "elf",
|
||||
"status": "validated",
|
||||
"lastValidated": "2026-01-13T12:00:00Z"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-3156",
|
||||
"name": "Baron Samedit",
|
||||
"severity": "high",
|
||||
"format": "elf",
|
||||
"status": "validated",
|
||||
"lastValidated": "2026-01-13T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 2,
|
||||
"validated": 2,
|
||||
"failed": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Workflow
|
||||
|
||||
```bash
|
||||
# 1. Fetch artifacts
|
||||
golden-pairs mirror CVE-2022-0847
|
||||
golden-pairs mirror CVE-2021-3156
|
||||
|
||||
# 2. Run diff analysis
|
||||
golden-pairs diff CVE-2022-0847 --output json > CVE-2022-0847/diff-report.json
|
||||
golden-pairs diff CVE-2021-3156 --output json > CVE-2021-3156/diff-report.json
|
||||
|
||||
# 3. Validate all
|
||||
golden-pairs validate --all
|
||||
# Expected output:
|
||||
# CVE-2022-0847: PASS (verdict=patched, confidence=0.95)
|
||||
# CVE-2021-3156: PASS (verdict=patched, confidence=0.92)
|
||||
# Summary: 2/2 passed
|
||||
```
|
||||
|
||||
## Test Cases
|
||||
|
||||
| Test | Description | Expected |
|
||||
|------|-------------|----------|
|
||||
| `DirtyPipe_Validate_Passes` | Full pipeline for CVE-2022-0847 | Verdict: patched, matches expected |
|
||||
| `BaronSamedit_Validate_Passes` | Full pipeline for CVE-2021-3156 | Verdict: patched, matches expected |
|
||||
| `Index_AllPairs_Listed` | Load index.json | All pairs enumerated |
|
||||
| `CI_Workflow_Succeeds` | Run validation in CI | All tests pass |
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|------------|--------|-------|
|
||||
| 2026-01-13 | Sprint created from advisory analysis. | Project Mgmt |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
- **APPROVED**: Start with ELF only; PrintNightmare conditional on PE support.
|
||||
- **APPROVED**: Use Debian snapshot archive for reproducible sudo packages.
|
||||
- **RISK**: Kernel binaries are very large; consider extracting specific .ko modules.
|
||||
- **RISK**: Package removal from archives; cache locally after first fetch.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Task 1-4 complete -> Dirty Pipe pair validated
|
||||
- Task 5-8 complete -> Baron Samedit pair validated
|
||||
- Task 10-12 complete -> Corpus published, CI integrated
|
||||
- Sprint and Batch complete
|
||||
@@ -1,19 +1,20 @@
|
||||
# Rekor Verification Technical Design
|
||||
|
||||
**Document ID**: DOCS-ATTEST-REKOR-001
|
||||
**Version**: 1.0
|
||||
**Last Updated**: 2025-12-14
|
||||
**Version**: 2.0
|
||||
**Last Updated**: 2026-01-13
|
||||
**Status**: Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. OVERVIEW
|
||||
|
||||
This document provides the comprehensive technical design for Rekor transparency log verification in StellaOps. It covers three key capabilities:
|
||||
This document provides the comprehensive technical design for Rekor transparency log verification in StellaOps. It covers four key capabilities:
|
||||
|
||||
1. **Merkle Proof Verification** - Cryptographic verification of inclusion proofs
|
||||
2. **Durable Retry Queue** - Reliable submission with failure recovery
|
||||
3. **Time Skew Validation** - Replay protection via timestamp validation
|
||||
4. **Tile-Based Verification (v2)** - Support for Rekor v2 Sunlight format
|
||||
|
||||
### Related Sprints
|
||||
|
||||
@@ -22,6 +23,7 @@ This document provides the comprehensive technical design for Rekor transparency
|
||||
| SPRINT_3000_0001_0001 | P0 | Merkle Proof Verification |
|
||||
| SPRINT_3000_0001_0002 | P1 | Rekor Retry Queue & Metrics |
|
||||
| SPRINT_3000_0001_0003 | P2 | Time Skew Validation |
|
||||
| SPRINT_3000_0001_0004 | P1 | Rekor v2 Tile-Based Verification |
|
||||
|
||||
---
|
||||
|
||||
@@ -405,6 +407,225 @@ public TimeSkewResult Validate(DateTimeOffset integratedTime, DateTimeOffset loc
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Tile-Based Verification (Rekor v2)
|
||||
|
||||
Rekor v2 introduces a tile-based log structure following the Sunlight/C2SP `tlog-tiles` specification. This enables offline-capable verification and more efficient proof computation.
|
||||
|
||||
#### 3.4.1 Architecture Overview
|
||||
|
||||
In tile-based logs, the Merkle tree is stored in fixed-size chunks (tiles) of 256 entries each:
|
||||
|
||||
```
|
||||
Tile Structure (256 entries/tile)
|
||||
───────────────────────────────────────────────────────────
|
||||
Level 2 (root)
|
||||
[Tile]
|
||||
/ \
|
||||
Level 1 (intermediate)
|
||||
[Tile 0] [Tile 1] ...
|
||||
/ \
|
||||
Level 0 (leaves)
|
||||
[Tile 0] [Tile 1] [Tile 2] [Tile 3] ...
|
||||
|
||||
Each tile contains up to 256 hashes (32 bytes each = 8KB max)
|
||||
```
|
||||
|
||||
#### 3.4.2 Log Version Configuration
|
||||
|
||||
StellaOps supports automatic version detection and explicit version selection:
|
||||
|
||||
```csharp
|
||||
public enum RekorLogVersion
|
||||
{
|
||||
Auto = 0, // Auto-detect based on endpoint availability
|
||||
V1 = 1, // Traditional Trillian-based Rekor (API proofs)
|
||||
V2 = 2 // Tile-based Sunlight format
|
||||
}
|
||||
```
|
||||
|
||||
**Version Selection Logic:**
|
||||
|
||||
| Version | PreferTileProofs | Result |
|
||||
|---------|------------------|--------|
|
||||
| V2 | (any) | Always use tile proofs |
|
||||
| V1 | (any) | Always use API proofs |
|
||||
| Auto | true | Prefer tile proofs if available |
|
||||
| Auto | false | Use API proofs (default) |
|
||||
|
||||
#### 3.4.3 Checkpoint Format
|
||||
|
||||
V2 checkpoints follow the `c2sp.org/tlog-tiles` format:
|
||||
|
||||
```
|
||||
rekor.sigstore.dev - 2605736670972794746
|
||||
<tree_size>
|
||||
<root_hash_base64>
|
||||
|
||||
- rekor.sigstore.dev <signature_base64>
|
||||
```
|
||||
|
||||
**Checkpoint Components:**
|
||||
- **Line 1**: Origin identifier (log name + instance)
|
||||
- **Line 2**: Tree size (number of leaves)
|
||||
- **Line 3**: Root hash (base64-encoded SHA-256)
|
||||
- **Blank line**: Separator
|
||||
- **Signature lines**: One or more `- <origin> <signature>` lines
|
||||
|
||||
#### 3.4.4 Tile Path Calculation
|
||||
|
||||
Tiles are fetched via URL paths following the scheme:
|
||||
|
||||
```
|
||||
GET {tile_base_url}/tile/{level}/{index:03d}[.p/{partial_width}]
|
||||
|
||||
Examples:
|
||||
- /tile/0/000 # Level 0, tile 0 (entries 0-255)
|
||||
- /tile/0/001 # Level 0, tile 1 (entries 256-511)
|
||||
- /tile/1/000 # Level 1, tile 0 (intermediate hashes)
|
||||
- /tile/0/042.p/128 # Partial tile with 128 entries
|
||||
```
|
||||
|
||||
#### 3.4.5 Implementation Classes
|
||||
|
||||
**IRekorTileClient Interface:**
|
||||
|
||||
```csharp
|
||||
public interface IRekorTileClient
|
||||
{
|
||||
Task<RekorTileCheckpoint?> GetCheckpointAsync(
|
||||
RekorBackend backend,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RekorTileData?> GetTileAsync(
|
||||
RekorBackend backend,
|
||||
int level,
|
||||
long index,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RekorTileEntry?> GetEntryAsync(
|
||||
RekorBackend backend,
|
||||
long logIndex,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RekorTileInclusionProof?> ComputeInclusionProofAsync(
|
||||
RekorBackend backend,
|
||||
long logIndex,
|
||||
long treeSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
**RekorTileData Model:**
|
||||
|
||||
```csharp
|
||||
public sealed class RekorTileData
|
||||
{
|
||||
public required int Level { get; init; }
|
||||
public required long Index { get; init; }
|
||||
public required int Width { get; init; } // Number of hashes (max 256)
|
||||
public required byte[] Hashes { get; init; } // Width * 32 bytes
|
||||
|
||||
public byte[] GetHash(int position)
|
||||
{
|
||||
if (position < 0 || position >= Width)
|
||||
throw new ArgumentOutOfRangeException(nameof(position));
|
||||
|
||||
var result = new byte[32];
|
||||
Array.Copy(Hashes, position * 32, result, 0, 32);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4.6 Proof Computation Algorithm
|
||||
|
||||
Computing an inclusion proof from tiles:
|
||||
|
||||
```python
|
||||
def compute_inclusion_proof(log_index, tree_size, tile_client):
|
||||
"""Compute inclusion proof by fetching necessary tiles."""
|
||||
proof_path = []
|
||||
level = 0
|
||||
index = log_index
|
||||
size = tree_size
|
||||
|
||||
while size > 1:
|
||||
tile_index = index // 256
|
||||
position_in_tile = index % 256
|
||||
|
||||
# Determine sibling position
|
||||
if index % 2 == 1:
|
||||
sibling_pos = position_in_tile - 1
|
||||
else:
|
||||
sibling_pos = position_in_tile + 1 if position_in_tile + 1 < size else None
|
||||
|
||||
if sibling_pos is not None:
|
||||
tile = tile_client.get_tile(level, tile_index)
|
||||
proof_path.append(tile.get_hash(sibling_pos))
|
||||
|
||||
index = index // 2
|
||||
size = (size + 1) // 2
|
||||
level += 1
|
||||
|
||||
return proof_path
|
||||
```
|
||||
|
||||
#### 3.4.7 Configuration
|
||||
|
||||
```yaml
|
||||
attestor:
|
||||
rekor:
|
||||
primary:
|
||||
url: https://rekor.sigstore.dev
|
||||
# Version: Auto, V1, or V2
|
||||
version: Auto
|
||||
# Custom tile base URL (optional, defaults to {url}/tile/)
|
||||
tile_base_url: ""
|
||||
# Log ID for multi-log environments (hex-encoded SHA-256)
|
||||
log_id: "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d"
|
||||
# Prefer tile proofs when version is Auto
|
||||
prefer_tile_proofs: false
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
|
||||
```bash
|
||||
# Rekor v2 Configuration
|
||||
REKOR_SERVER_URL=https://rekor.sigstore.dev
|
||||
REKOR_VERSION=Auto # Auto, V1, or V2
|
||||
REKOR_TILE_BASE_URL= # Optional custom tile endpoint
|
||||
REKOR_LOG_ID=c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d
|
||||
REKOR_PREFER_TILE_PROOFS=false
|
||||
```
|
||||
|
||||
#### 3.4.8 Offline Verification Benefits
|
||||
|
||||
Tile-based verification enables true offline capability:
|
||||
|
||||
1. **Pre-fetch tiles**: Download all necessary tiles during online phase
|
||||
2. **Bundle checkpoint**: Include signed checkpoint with offline kit
|
||||
3. **Local proof computation**: Compute proofs entirely from local tile data
|
||||
4. **No API dependency**: Verification works without Rekor connectivity
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Offline Verification │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Checkpoint │────►│ Tile Cache │────►│ Proof │ │
|
||||
│ │ (signed) │ │ (local) │ │ Verifier │ │
|
||||
│ └─────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
│ Advantages: │
|
||||
│ - No network round-trips for proof fetching │
|
||||
│ - Deterministic verification (same tiles = same proof) │
|
||||
│ - Caching efficiency (tiles are immutable) │
|
||||
│ - Air-gap compatible │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. DATA FLOW
|
||||
@@ -688,4 +909,7 @@ attestor:
|
||||
- [RFC 6962: Certificate Transparency](https://datatracker.ietf.org/doc/html/rfc6962)
|
||||
- [Sigstore Rekor](https://github.com/sigstore/rekor)
|
||||
- [Transparency.dev Checkpoint Format](https://github.com/transparency-dev/formats)
|
||||
- [C2SP tlog-tiles Specification](https://c2sp.org/tlog-tiles) - Tile-based transparency log format
|
||||
- [Sunlight CT Log](https://github.com/FiloSottile/sunlight) - Reference implementation for tile-based logs
|
||||
- [Sigstore Rekor v2 Announcement](https://blog.sigstore.dev/) - Official Rekor v2 migration information
|
||||
- [Advisory: Rekor Integration Technical Reference](../../../product/advisories/14-Dec-2025%20-%20Rekor%20Integration%20Technical%20Reference.md)
|
||||
|
||||
355
docs/modules/scanner/binary-diff-attestation.md
Normal file
355
docs/modules/scanner/binary-diff-attestation.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Binary Diff Attestation
|
||||
|
||||
## Overview
|
||||
|
||||
Binary Diff Attestation enables verification of binary-level changes between container images, producing cryptographically signed evidence of what changed at the ELF/PE section level. This capability is essential for:
|
||||
|
||||
- **Vendor backport detection**: Identify when a vendor has patched a binary without changing version numbers
|
||||
- **Supply chain verification**: Prove that expected changes (and no unexpected changes) occurred between releases
|
||||
- **VEX evidence generation**: Provide concrete evidence for "not_affected" or "fixed" vulnerability status claims
|
||||
- **Audit trail**: Maintain verifiable records of binary modifications across deployments
|
||||
|
||||
### Relationship to SBOM and VEX
|
||||
|
||||
Binary diff attestations complement SBOM and VEX documents:
|
||||
|
||||
| Artifact | Purpose | Granularity |
|
||||
|----------|---------|-------------|
|
||||
| SBOM | Inventory of components | Package/library level |
|
||||
| VEX | Exploitability status | Vulnerability level |
|
||||
| Binary Diff Attestation | Change evidence | Section/function level |
|
||||
|
||||
The attestation provides the *evidence* that supports VEX claims. For example, a VEX statement claiming a CVE is "fixed" due to a vendor backport can reference the binary diff attestation showing the `.text` section hash changed.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Diagram
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Binary Diff Attestation Flow │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ OCI │ │ Layer │ │ Binary │ │ Section │ │
|
||||
│ │ Registry │───▶│ Extraction │───▶│ Detection │───▶│ Hash │ │
|
||||
│ │ Client │ │ │ │ │ │ Extractor │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ └──────┬──────┘ │
|
||||
│ │ │
|
||||
│ Base Image ─────────────────────────────────────┐ │ │
|
||||
│ Target Image ───────────────────────────────────┤ ▼ │
|
||||
│ │ ┌─────────────┐ │
|
||||
│ └─▶│ Diff │ │
|
||||
│ │ Computation │ │
|
||||
│ └──────┬──────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ DSSE │◀───│ Predicate │◀───│ Finding │◀───│ Verdict │ │
|
||||
│ │ Signer │ │ Builder │ │ Aggregation │ │ Classifier │ │
|
||||
│ └──────┬──────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Rekor │ │ File │ │
|
||||
│ │ Submission │ │ Output │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
| Component | Location | Responsibility |
|
||||
|-----------|----------|----------------|
|
||||
| `ElfSectionHashExtractor` | `Scanner.Analyzers.Native` | Extract per-section SHA-256 hashes from ELF binaries |
|
||||
| `BinaryDiffService` | `Cli.Services` | Orchestrate diff computation between two images |
|
||||
| `BinaryDiffPredicateBuilder` | `Attestor.StandardPredicates` | Construct BinaryDiffV1 in-toto predicates |
|
||||
| `BinaryDiffDsseSigner` | `Attestor.StandardPredicates` | Sign predicates with DSSE envelopes |
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Image Resolution**: Resolve base and target image references to manifest digests
|
||||
2. **Layer Extraction**: Download and extract layers from both images
|
||||
3. **Binary Identification**: Identify ELF binaries in both filesystems
|
||||
4. **Section Hash Computation**: Compute SHA-256 for each target section in each binary
|
||||
5. **Diff Computation**: Compare section hashes between base and target
|
||||
6. **Verdict Classification**: Classify changes as patched/vanilla/unknown
|
||||
7. **Predicate Construction**: Build BinaryDiffV1 predicate with findings
|
||||
8. **DSSE Signing**: Sign predicate and optionally submit to Rekor
|
||||
|
||||
## ELF Section Hashing
|
||||
|
||||
### Target Sections
|
||||
|
||||
The following ELF sections are analyzed for hash computation:
|
||||
|
||||
| Section | Purpose | Backport Relevance |
|
||||
|---------|---------|-------------------|
|
||||
| `.text` | Executable code | **High** - Patched functions modify this section |
|
||||
| `.rodata` | Read-only data (strings, constants) | Medium - String constants may change with patches |
|
||||
| `.data` | Initialized global/static variables | Low - Rarely changes for security patches |
|
||||
| `.symtab` | Symbol table (function names, addresses) | **High** - Function signature changes |
|
||||
| `.dynsym` | Dynamic symbols (exports) | **High** - Exported API changes |
|
||||
|
||||
### Hash Algorithm
|
||||
|
||||
**Primary**: SHA-256
|
||||
- Industry standard, widely supported
|
||||
- Collision-resistant for security applications
|
||||
|
||||
**Optional**: BLAKE3-256
|
||||
- Faster computation for large binaries
|
||||
- Enabled via configuration
|
||||
|
||||
### Hash Computation
|
||||
|
||||
```
|
||||
For each ELF binary:
|
||||
1. Parse ELF header
|
||||
2. Locate section headers
|
||||
3. For each target section:
|
||||
a. Read section contents
|
||||
b. Compute SHA-256(contents)
|
||||
c. Store: {name, offset, size, sha256}
|
||||
4. Sort sections by name (lexicographic)
|
||||
5. Return ElfSectionHashSet
|
||||
```
|
||||
|
||||
### Determinism Guarantees
|
||||
|
||||
All operations produce deterministic output:
|
||||
|
||||
| Aspect | Guarantee |
|
||||
|--------|-----------|
|
||||
| Section ordering | Sorted lexicographically by name |
|
||||
| Hash format | Lowercase hexadecimal, no prefix |
|
||||
| Timestamps | From injected `TimeProvider` |
|
||||
| JSON serialization | RFC 8785 canonical JSON |
|
||||
|
||||
## BinaryDiffV1 Predicate
|
||||
|
||||
### Schema Overview
|
||||
|
||||
The `BinaryDiffV1` predicate follows in-toto attestation format:
|
||||
|
||||
```json
|
||||
{
|
||||
"_type": "https://in-toto.io/Statement/v1",
|
||||
"subject": [
|
||||
{
|
||||
"name": "docker://repo/app@sha256:target...",
|
||||
"digest": { "sha256": "target..." }
|
||||
}
|
||||
],
|
||||
"predicateType": "stellaops.binarydiff.v1",
|
||||
"predicate": {
|
||||
"inputs": {
|
||||
"base": { "digest": "sha256:base..." },
|
||||
"target": { "digest": "sha256:target..." }
|
||||
},
|
||||
"findings": [...],
|
||||
"metadata": {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Predicate Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `subjects` | array | Target image references with digests |
|
||||
| `inputs.base` | object | Base image reference |
|
||||
| `inputs.target` | object | Target image reference |
|
||||
| `findings` | array | Per-binary diff findings |
|
||||
| `metadata` | object | Tool version, timestamp, config |
|
||||
|
||||
### Finding Structure
|
||||
|
||||
Each finding represents a binary comparison:
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "/usr/lib/libssl.so.3",
|
||||
"changeType": "modified",
|
||||
"binaryFormat": "elf",
|
||||
"sectionDeltas": [
|
||||
{ "section": ".text", "status": "modified" },
|
||||
{ "section": ".rodata", "status": "identical" }
|
||||
],
|
||||
"confidence": 0.95,
|
||||
"verdict": "patched"
|
||||
}
|
||||
```
|
||||
|
||||
### Verdicts
|
||||
|
||||
| Verdict | Meaning | Confidence Threshold |
|
||||
|---------|---------|---------------------|
|
||||
| `patched` | Binary shows evidence of security patch | >= 0.90 |
|
||||
| `vanilla` | Binary matches upstream/unmodified | >= 0.95 |
|
||||
| `unknown` | Cannot determine patch status | < 0.90 |
|
||||
| `incompatible` | Cannot compare (different architecture, etc.) | N/A |
|
||||
|
||||
## DSSE Attestation
|
||||
|
||||
### Envelope Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"payloadType": "stellaops.binarydiff.v1",
|
||||
"payload": "<base64-encoded predicate>",
|
||||
"signatures": [
|
||||
{
|
||||
"keyid": "...",
|
||||
"sig": "<base64-encoded signature>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Signature Algorithm
|
||||
|
||||
- **Default**: Ed25519
|
||||
- **Alternative**: ECDSA P-256, RSA-PSS (via `ICryptoProviderRegistry`)
|
||||
- **Keyless**: Sigstore Fulcio certificate chain
|
||||
|
||||
### Rekor Submission
|
||||
|
||||
When Rekor is enabled:
|
||||
|
||||
1. DSSE envelope is submitted to Rekor transparency log
|
||||
2. Inclusion proof is retrieved
|
||||
3. Rekor metadata is stored in result
|
||||
|
||||
```json
|
||||
{
|
||||
"rekorLogIndex": 12345678,
|
||||
"rekorEntryId": "abc123...",
|
||||
"integratedTime": "2026-01-13T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
Binary diff attestations can be verified with:
|
||||
|
||||
```bash
|
||||
# Using cosign
|
||||
cosign verify-attestation \
|
||||
--type stellaops.binarydiff.v1 \
|
||||
--certificate-identity-regexp '.*' \
|
||||
--certificate-oidc-issuer-regexp '.*' \
|
||||
docker://repo/app:1.0.1
|
||||
|
||||
# Using stella CLI
|
||||
stella verify attestation ./binarydiff.dsse.json \
|
||||
--type stellaops.binarydiff.v1
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### VEX Mapping
|
||||
|
||||
Binary diff evidence can support VEX claims:
|
||||
|
||||
```json
|
||||
{
|
||||
"vulnerability": "CVE-2024-1234",
|
||||
"status": "fixed",
|
||||
"justification": "vulnerable_code_not_present",
|
||||
"detail": "Vendor backport applied; evidence in binary diff attestation",
|
||||
"evidence": {
|
||||
"attestationRef": "sha256:dsse-envelope-hash...",
|
||||
"finding": {
|
||||
"path": "/usr/lib/libssl.so.3",
|
||||
"verdict": "patched",
|
||||
"confidence": 0.95
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Policy Engine
|
||||
|
||||
Policy rules can reference binary diff evidence:
|
||||
|
||||
```rego
|
||||
# Accept high-confidence patch verdicts as mitigation
|
||||
allow contains decision if {
|
||||
input.binaryDiff.findings[_].verdict == "patched"
|
||||
input.binaryDiff.findings[_].confidence >= 0.90
|
||||
decision := {
|
||||
"action": "accept",
|
||||
"reason": "Binary diff shows patched code",
|
||||
"evidence": input.binaryDiff.attestationRef
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SBOM Properties
|
||||
|
||||
Section hashes appear in SBOM component properties:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "library",
|
||||
"name": "libssl.so.3",
|
||||
"properties": [
|
||||
{"name": "evidence:section:.text:sha256", "value": "abc123..."},
|
||||
{"name": "evidence:section:.rodata:sha256", "value": "def456..."},
|
||||
{"name": "evidence:extractor-version", "value": "1.0.0"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Scanner Options
|
||||
|
||||
```yaml
|
||||
scanner:
|
||||
native:
|
||||
sectionHashes:
|
||||
enabled: true
|
||||
algorithms:
|
||||
- sha256
|
||||
- blake3 # optional
|
||||
sections:
|
||||
- .text
|
||||
- .rodata
|
||||
- .data
|
||||
- .symtab
|
||||
- .dynsym
|
||||
maxSectionSize: 104857600 # 100MB limit
|
||||
```
|
||||
|
||||
### CLI Options
|
||||
|
||||
See [CLI Reference](../../API_CLI_REFERENCE.md#stella-scan-diff) for full option documentation.
|
||||
|
||||
## Limitations and Future Work
|
||||
|
||||
### Current Limitations
|
||||
|
||||
1. **ELF only**: PE and Mach-O support planned for M2
|
||||
2. **Single platform**: Multi-platform diff requires multiple invocations
|
||||
3. **No function-level analysis**: Section-level granularity only
|
||||
4. **Confidence scoring**: Based on section changes, not semantic analysis
|
||||
|
||||
### Roadmap
|
||||
|
||||
| Milestone | Capability |
|
||||
|-----------|------------|
|
||||
| M2 | PE section analysis for Windows containers |
|
||||
| M2 | Mach-O section analysis for macOS binaries |
|
||||
| M3 | Vendor backport corpus with curated test fixtures |
|
||||
| M3 | Function-level diff using DWARF debug info |
|
||||
| M4 | ML-based verdict classification |
|
||||
|
||||
## References
|
||||
|
||||
- [BinaryDiffV1 JSON Schema](../../schemas/binarydiff-v1.schema.json)
|
||||
- [in-toto Attestation Specification](https://github.com/in-toto/attestation)
|
||||
- [DSSE Envelope Specification](https://github.com/secure-systems-lab/dsse)
|
||||
- [ELF Specification](https://refspecs.linuxfoundation.org/elf/elf.pdf)
|
||||
@@ -184,6 +184,18 @@ attestor:
|
||||
# Rekor server URL (default: public Sigstore Rekor)
|
||||
serverUrl: "https://rekor.sigstore.dev"
|
||||
|
||||
# Log version: Auto, V1, or V2 (V2 uses tile-based Sunlight format)
|
||||
version: Auto
|
||||
|
||||
# Log ID for multi-log environments (hex-encoded SHA-256)
|
||||
logId: "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d"
|
||||
|
||||
# Tile base URL for V2 (optional, defaults to {serverUrl}/tile/)
|
||||
tileBaseUrl: ""
|
||||
|
||||
# Prefer tile proofs when version is Auto
|
||||
preferTileProofs: false
|
||||
|
||||
# Submission tier: graph-only | with-edges
|
||||
tier: graph-only
|
||||
|
||||
@@ -225,7 +237,9 @@ attestor:
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Rekor Verification Technical Design](../modules/attestor/rekor-verification-design.md) - Full technical design including v2 tile support
|
||||
- [Attestor AGENTS.md](../../src/Attestor/StellaOps.Attestor/AGENTS.md)
|
||||
- [Scanner Score Proofs API](../api/scanner-score-proofs-api.md)
|
||||
- [Offline Kit Specification](../OFFLINE_KIT.md)
|
||||
- [Sigstore Rekor Documentation](https://docs.sigstore.dev/rekor/overview/)
|
||||
- [C2SP tlog-tiles Specification](https://c2sp.org/tlog-tiles) - Tile-based transparency log format (v2)
|
||||
|
||||
344
docs/schemas/binarydiff-v1.schema.json
Normal file
344
docs/schemas/binarydiff-v1.schema.json
Normal file
@@ -0,0 +1,344 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://stellaops.io/schemas/binarydiff-v1.schema.json",
|
||||
"title": "BinaryDiffV1",
|
||||
"description": "In-toto predicate schema for binary-level diff attestations between container images",
|
||||
"type": "object",
|
||||
"required": ["predicateType", "inputs", "findings", "metadata"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"predicateType": {
|
||||
"const": "stellaops.binarydiff.v1",
|
||||
"description": "Predicate type identifier"
|
||||
},
|
||||
"inputs": {
|
||||
"$ref": "#/$defs/BinaryDiffInputs",
|
||||
"description": "Base and target image references"
|
||||
},
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/BinaryDiffFinding"
|
||||
},
|
||||
"description": "Per-binary diff findings"
|
||||
},
|
||||
"metadata": {
|
||||
"$ref": "#/$defs/BinaryDiffMetadata",
|
||||
"description": "Analysis metadata"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"BinaryDiffInputs": {
|
||||
"type": "object",
|
||||
"required": ["base", "target"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"base": {
|
||||
"$ref": "#/$defs/ImageReference",
|
||||
"description": "Base image reference"
|
||||
},
|
||||
"target": {
|
||||
"$ref": "#/$defs/ImageReference",
|
||||
"description": "Target image reference"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageReference": {
|
||||
"type": "object",
|
||||
"required": ["digest"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reference": {
|
||||
"type": "string",
|
||||
"description": "Full image reference (e.g., docker://repo/image:tag)",
|
||||
"examples": ["docker://registry.example.com/app:1.0.0"]
|
||||
},
|
||||
"digest": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$",
|
||||
"description": "Image digest in sha256:hex format"
|
||||
},
|
||||
"manifestDigest": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$",
|
||||
"description": "Platform-specific manifest digest"
|
||||
},
|
||||
"platform": {
|
||||
"$ref": "#/$defs/Platform"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Platform": {
|
||||
"type": "object",
|
||||
"required": ["os", "architecture"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"os": {
|
||||
"type": "string",
|
||||
"description": "Operating system (e.g., linux, windows)",
|
||||
"examples": ["linux", "windows"]
|
||||
},
|
||||
"architecture": {
|
||||
"type": "string",
|
||||
"description": "CPU architecture (e.g., amd64, arm64)",
|
||||
"examples": ["amd64", "arm64", "386"]
|
||||
},
|
||||
"variant": {
|
||||
"type": "string",
|
||||
"description": "Architecture variant (e.g., v8 for arm64)",
|
||||
"examples": ["v7", "v8"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"BinaryDiffFinding": {
|
||||
"type": "object",
|
||||
"required": ["path", "changeType", "binaryFormat"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path within the container filesystem",
|
||||
"examples": ["/usr/lib/libssl.so.3", "/usr/bin/openssl"]
|
||||
},
|
||||
"changeType": {
|
||||
"type": "string",
|
||||
"enum": ["added", "removed", "modified", "unchanged"],
|
||||
"description": "Type of change detected"
|
||||
},
|
||||
"binaryFormat": {
|
||||
"type": "string",
|
||||
"enum": ["elf", "pe", "macho", "unknown"],
|
||||
"description": "Binary format detected"
|
||||
},
|
||||
"layerDigest": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$",
|
||||
"description": "Layer digest that introduced this file/change"
|
||||
},
|
||||
"baseHashes": {
|
||||
"$ref": "#/$defs/SectionHashSet",
|
||||
"description": "Section hashes from base image binary"
|
||||
},
|
||||
"targetHashes": {
|
||||
"$ref": "#/$defs/SectionHashSet",
|
||||
"description": "Section hashes from target image binary"
|
||||
},
|
||||
"sectionDeltas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SectionDelta"
|
||||
},
|
||||
"description": "Per-section comparison results"
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Confidence score for verdict (0.0-1.0)"
|
||||
},
|
||||
"verdict": {
|
||||
"type": "string",
|
||||
"enum": ["patched", "vanilla", "unknown", "incompatible"],
|
||||
"description": "Classification of the binary change"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SectionHashSet": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"buildId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]+$",
|
||||
"description": "GNU Build-ID from .note.gnu.build-id section"
|
||||
},
|
||||
"fileHash": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$",
|
||||
"description": "SHA-256 hash of the entire file"
|
||||
},
|
||||
"extractorVersion": {
|
||||
"type": "string",
|
||||
"description": "Version of the section hash extractor"
|
||||
},
|
||||
"sections": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/SectionInfo"
|
||||
},
|
||||
"description": "Map of section name to section info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SectionInfo": {
|
||||
"type": "object",
|
||||
"required": ["sha256", "size"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$",
|
||||
"description": "SHA-256 hash of section contents"
|
||||
},
|
||||
"blake3": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$",
|
||||
"description": "Optional BLAKE3-256 hash of section contents"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Section size in bytes"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Section offset in file"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "ELF section type (e.g., SHT_PROGBITS)"
|
||||
},
|
||||
"flags": {
|
||||
"type": "string",
|
||||
"description": "ELF section flags (e.g., SHF_ALLOC | SHF_EXECINSTR)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SectionDelta": {
|
||||
"type": "object",
|
||||
"required": ["section", "status"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"section": {
|
||||
"type": "string",
|
||||
"description": "Section name (e.g., .text, .rodata)",
|
||||
"examples": [".text", ".rodata", ".data", ".symtab", ".dynsym"]
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["identical", "modified", "added", "removed"],
|
||||
"description": "Section comparison status"
|
||||
},
|
||||
"baseSha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$",
|
||||
"description": "SHA-256 of section in base binary"
|
||||
},
|
||||
"targetSha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$",
|
||||
"description": "SHA-256 of section in target binary"
|
||||
},
|
||||
"sizeDelta": {
|
||||
"type": "integer",
|
||||
"description": "Size difference (target - base) in bytes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"BinaryDiffMetadata": {
|
||||
"type": "object",
|
||||
"required": ["toolVersion", "analysisTimestamp"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"toolVersion": {
|
||||
"type": "string",
|
||||
"description": "Version of the binary diff tool",
|
||||
"examples": ["1.0.0", "2026.01.0"]
|
||||
},
|
||||
"analysisTimestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "UTC timestamp of analysis (ISO-8601)"
|
||||
},
|
||||
"configDigest": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$",
|
||||
"description": "SHA-256 of analysis configuration for reproducibility"
|
||||
},
|
||||
"totalBinaries": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Total number of binaries analyzed"
|
||||
},
|
||||
"modifiedBinaries": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Number of binaries with modifications"
|
||||
},
|
||||
"analyzedSections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "List of section names analyzed",
|
||||
"examples": [[".text", ".rodata", ".data", ".symtab", ".dynsym"]]
|
||||
},
|
||||
"hashAlgorithms": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["sha256", "blake3"]
|
||||
},
|
||||
"description": "Hash algorithms used"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
{
|
||||
"predicateType": "stellaops.binarydiff.v1",
|
||||
"inputs": {
|
||||
"base": {
|
||||
"reference": "docker://registry.example.com/app:1.0.0",
|
||||
"digest": "sha256:abc123def456789012345678901234567890123456789012345678901234abcd",
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"architecture": "amd64"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"reference": "docker://registry.example.com/app:1.0.1",
|
||||
"digest": "sha256:def456abc789012345678901234567890123456789012345678901234567efgh",
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"architecture": "amd64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"path": "/usr/lib/libssl.so.3",
|
||||
"changeType": "modified",
|
||||
"binaryFormat": "elf",
|
||||
"sectionDeltas": [
|
||||
{
|
||||
"section": ".text",
|
||||
"status": "modified",
|
||||
"baseSha256": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
"targetSha256": "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
|
||||
"sizeDelta": 256
|
||||
},
|
||||
{
|
||||
"section": ".rodata",
|
||||
"status": "identical",
|
||||
"baseSha256": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
||||
"targetSha256": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
||||
"sizeDelta": 0
|
||||
}
|
||||
],
|
||||
"confidence": 0.95,
|
||||
"verdict": "patched"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"toolVersion": "1.0.0",
|
||||
"analysisTimestamp": "2026-01-13T12:00:00Z",
|
||||
"totalBinaries": 156,
|
||||
"modifiedBinaries": 3,
|
||||
"analyzedSections": [".text", ".rodata", ".data", ".symtab", ".dynsym"],
|
||||
"hashAlgorithms": ["sha256"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user