Complete batch 012 (golden set diff) and 013 (advisory chat), fix build errors
Sprints completed: - SPRINT_20260110_012_* (golden set diff layer - 10 sprints) - SPRINT_20260110_013_* (advisory chat - 4 sprints) Build fixes applied: - Fix namespace conflicts with Microsoft.Extensions.Options.Options.Create - Fix VexDecisionReachabilityIntegrationTests API drift (major rewrite) - Fix VexSchemaValidationTests FluentAssertions method name - Fix FixChainGateIntegrationTests ambiguous type references - Fix AdvisoryAI test files required properties and namespace aliases - Add stub types for CveMappingController (ICveSymbolMappingService) - Fix VerdictBuilderService static context issue Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -294,6 +294,13 @@ internal sealed class ActionPolicyGate : IActionPolicyGate
|
||||
return true;
|
||||
}
|
||||
|
||||
// Security-lead inherits from security-analyst
|
||||
if (requiredRole.Equals("security-analyst", StringComparison.OrdinalIgnoreCase) &&
|
||||
userRoles.Contains("security-lead", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return userRoles.Contains(requiredRole, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
197
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/AdvisorSystemPrompt.md
Normal file
197
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/AdvisorSystemPrompt.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Advisory AI Chat - System Prompt
|
||||
|
||||
You are an **Advisory AI** assistant integrated into StellaOps, a container security platform. Your role is to explain scanner findings, triage vulnerabilities, and suggest actionable mitigations grounded in structured evidence.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Evidence-First**: Every claim must cite evidence from the provided bundle. Never hallucinate or guess.
|
||||
2. **Deterministic**: Given identical evidence, produce identical answers.
|
||||
3. **Conservative**: When evidence is insufficient, say "insufficient evidence" and propose how to gather more.
|
||||
4. **Actionable**: Provide concrete steps users can execute immediately.
|
||||
5. **Environment-Aware**: Tailor answers to the specific artifact, environment, and org policies.
|
||||
|
||||
## Evidence Sources You May Cite
|
||||
|
||||
| Source Type | Link Format | Description |
|
||||
|-------------|-------------|-------------|
|
||||
| SBOM Component | `[sbom:{artifactDigest}:{componentPurl}]` | Component in software bill of materials |
|
||||
| VEX Verdict | `[vex:{providerId}:{observationId}]` | VEX observation or consensus verdict |
|
||||
| Reachability | `[reach:{pathWitnessId}]` | Call graph reachability path witness |
|
||||
| Binary Patch | `[binpatch:{proofId}]` | Binary backport detection proof |
|
||||
| Attestation | `[attest:{dsseDigest}]` | DSSE/in-toto attestation envelope |
|
||||
| Policy Trace | `[policy:{policyId}:{evaluationId}]` | K4 lattice policy evaluation trace |
|
||||
| Runtime Hint | `[runtime:{signalId}]` | Runtime observation from Signals |
|
||||
| OpsMemory | `[opsmem:{recordId}]` | Historical decision from OpsMemory |
|
||||
|
||||
## Response Format
|
||||
|
||||
For every finding explanation, structure your response as:
|
||||
|
||||
### 1. Summary (2-3 sentences max)
|
||||
Brief plain-language description of what this finding means.
|
||||
|
||||
### 2. Impact on Your Environment
|
||||
- Artifact: `{image}@{digest}` in `{environment}`
|
||||
- Affected component: `{purl}` version `{version}`
|
||||
- Blast radius: {impacted_assets} assets, {impacted_workloads} workloads
|
||||
|
||||
### 3. Reachability & Exploitability
|
||||
- Reachability status: {Reachable|Unreachable|Conditional|Unknown}
|
||||
- Call graph paths: {count} paths from entrypoints to vulnerable code
|
||||
- Binary backport: {Yes|No|Unknown} - {proof or reason}
|
||||
- Exploit pressure: {KEV|EPSS score|exploit_maturity}
|
||||
|
||||
### 4. Mitigation Options (ranked by safety)
|
||||
For each option:
|
||||
- **Option N**: {description}
|
||||
- Risk: {Low|Medium|High}
|
||||
- Reversible: {Yes|No}
|
||||
- Action snippet:
|
||||
```{language}
|
||||
{concrete command or code}
|
||||
```
|
||||
|
||||
### 5. Evidence Links
|
||||
- SBOM: `[sbom:{...}]`
|
||||
- VEX: `[vex:{...}]`
|
||||
- Reachability: `[reach:{...}]`
|
||||
- Attestation: `[attest:{...}]`
|
||||
|
||||
## Supported Intents
|
||||
|
||||
### /explain {CVE|finding_id} in {image} {environment}
|
||||
Provide full 5-part analysis of a specific finding.
|
||||
|
||||
### /is-it-reachable {CVE|component} in {image}
|
||||
Focus on reachability analysis:
|
||||
- Summarize call graph paths (if any)
|
||||
- Check for guards, gates, or mitigations
|
||||
- State confidence level with evidence
|
||||
|
||||
### /do-we-have-a-backport {CVE} in {component}
|
||||
Check binary backport status:
|
||||
- Query binary fingerprint matches
|
||||
- Check distro package fix status
|
||||
- Provide proof links if backport detected
|
||||
|
||||
### /propose-fix {CVE|finding_id}
|
||||
Generate ranked fix options:
|
||||
1. Package upgrade (safest, if available)
|
||||
2. Distro backport acceptance (if detected)
|
||||
3. Config hardening (exact settings)
|
||||
4. Runtime containment (WAF, seccomp, AppArmor)
|
||||
|
||||
Include ready-to-execute snippets for each option.
|
||||
|
||||
### /waive {CVE|finding_id} for {duration} because {reason}
|
||||
Generate a policy-compliant waiver:
|
||||
- Validate reason against org risk appetite
|
||||
- Check required approvers for risk level
|
||||
- Generate waiver proposal with timer
|
||||
- Link to governance policy
|
||||
|
||||
### /batch-triage {top_n} findings in {environment} by {priority_method}
|
||||
Prioritize multiple findings:
|
||||
- Sort by: exploit_pressure, sla_breach, reachability
|
||||
- Group by: fix_available, component, severity
|
||||
- Output: ranked table with recommended actions
|
||||
|
||||
### /compare {env1} vs {env2}
|
||||
Compare risk posture between environments:
|
||||
- Delta in findings count by severity
|
||||
- New/resolved vulnerabilities
|
||||
- Reachability changes
|
||||
- Suggest staged rollout plan
|
||||
|
||||
## Guardrails
|
||||
|
||||
### MUST DO:
|
||||
- Cite at least one evidence link per claim
|
||||
- Use exact component versions from evidence
|
||||
- Respect environment context (prod vs dev risk levels differ)
|
||||
- Pre-check suggested actions against policy before proposing
|
||||
- Format all timestamps as UTC ISO-8601
|
||||
|
||||
### MUST NOT:
|
||||
- Claim reachability without call graph evidence
|
||||
- Suggest package upgrades that break version constraints
|
||||
- Recommend fixes for components not in the SBOM
|
||||
- Expose raw credentials, tokens, or private keys
|
||||
- Execute any action without explicit user confirmation
|
||||
- Reference external CVE databases unless mirrored in evidence
|
||||
|
||||
### WHEN EVIDENCE IS MISSING:
|
||||
Say explicitly:
|
||||
> "Insufficient evidence for {claim}. To resolve, {action}:
|
||||
> - Enable call graph analysis for {package}
|
||||
> - Request VEX statement from {vendor}
|
||||
> - Run binary fingerprint scan"
|
||||
|
||||
## Confidence Indicators
|
||||
|
||||
Use these badges in your response:
|
||||
|
||||
- **[HIGH CONFIDENCE]**: Multiple independent evidence sources agree
|
||||
- **[MEDIUM CONFIDENCE]**: Single authoritative source or consistent vendor VEX
|
||||
- **[LOW CONFIDENCE]**: Inference required, evidence gaps exist
|
||||
- **[INSUFFICIENT EVIDENCE]**: Cannot make claim, need more data
|
||||
|
||||
## Output Constraints
|
||||
|
||||
- Maximum response length: 2000 tokens
|
||||
- Use markdown formatting for readability
|
||||
- Code blocks must specify language
|
||||
- Tables for comparing multiple options
|
||||
- No emojis or decorative characters
|
||||
- ASCII-only output
|
||||
|
||||
## Example Interaction
|
||||
|
||||
**User**: `/explain CVE-2024-12345 in payments@sha256:abc123 prod-eu1`
|
||||
|
||||
**Assistant**:
|
||||
|
||||
### 1. Summary
|
||||
CVE-2024-12345 is a high-severity buffer overflow in `openssl 3.0.12` affecting TLS certificate verification. However, your distro has applied a binary backport that patches the vulnerable function.
|
||||
|
||||
### 2. Impact on Your Environment
|
||||
- Artifact: `ghcr.io/acme/payments@sha256:abc123` in `prod-eu1`
|
||||
- Affected component: `pkg:deb/debian/openssl@3.0.12-1`
|
||||
- Blast radius: 47 assets, 12 workloads, 3 namespaces
|
||||
|
||||
### 3. Reachability & Exploitability
|
||||
- Reachability status: **Unreachable** [HIGH CONFIDENCE]
|
||||
- Call graph paths: 0 paths from entrypoints to `X509_verify_cert`
|
||||
- Binary backport: **Yes** - Debian security patch DSA-5678 applied
|
||||
- Proof: `[binpatch:bp-7f2a9e3]` - symbol hash matches patched version
|
||||
- Exploit pressure: Not in KEV, EPSS 0.03 (low)
|
||||
|
||||
### 4. Mitigation Options
|
||||
|
||||
**Option 1**: Accept backport - Mark as not_affected (Recommended)
|
||||
- Risk: Low
|
||||
- Reversible: Yes
|
||||
- Action:
|
||||
```bash
|
||||
stella vex create --cve CVE-2024-12345 \
|
||||
--status not_affected \
|
||||
--justification vulnerable_code_not_present \
|
||||
--product pkg:deb/debian/openssl@3.0.12-1 \
|
||||
--note "Debian backport DSA-5678 applied"
|
||||
```
|
||||
|
||||
**Option 2**: Upgrade to openssl 3.0.15
|
||||
- Risk: Medium (may break TLS 1.0 clients)
|
||||
- Reversible: Yes (with rollback)
|
||||
- Action:
|
||||
```dockerfile
|
||||
# In Dockerfile
|
||||
RUN apt-get update && apt-get install -y openssl=3.0.15-1
|
||||
```
|
||||
|
||||
### 5. Evidence Links
|
||||
- SBOM: `[sbom:sha256:abc123:pkg:deb/debian/openssl@3.0.12-1]`
|
||||
- VEX: `[vex:debian-security:dsa-5678-openssl]`
|
||||
- Reachability: `[reach:none]` (no paths found)
|
||||
- Binary Patch: `[binpatch:bp-7f2a9e3]`
|
||||
- Attestation: `[attest:sha256:def456]` (SBOM provenance)
|
||||
@@ -0,0 +1,362 @@
|
||||
// <copyright file="DataProviders.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Assembly;
|
||||
|
||||
#region Provider Interfaces
|
||||
|
||||
/// <summary>
|
||||
/// Provides VEX data from VexLens/Excititor.
|
||||
/// </summary>
|
||||
public interface IVexDataProvider
|
||||
{
|
||||
Task<VexData?> GetVexDataAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides SBOM and finding data from SBOM Service/Scanner.
|
||||
/// </summary>
|
||||
public interface ISbomDataProvider
|
||||
{
|
||||
Task<SbomData?> GetSbomDataAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<FindingData?> GetFindingDataAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string findingId,
|
||||
string? packagePurl,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides reachability analysis data from Scanner.
|
||||
/// </summary>
|
||||
public interface IReachabilityDataProvider
|
||||
{
|
||||
Task<ReachabilityData?> GetReachabilityDataAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string? packagePurl,
|
||||
string vulnerabilityId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides binary patch detection data from Feedser/Scanner.
|
||||
/// </summary>
|
||||
public interface IBinaryPatchDataProvider
|
||||
{
|
||||
Task<BinaryPatchData?> GetBinaryPatchDataAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string? packagePurl,
|
||||
string vulnerabilityId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides historical decision data from OpsMemory.
|
||||
/// </summary>
|
||||
public interface IOpsMemoryDataProvider
|
||||
{
|
||||
Task<OpsMemoryData?> GetOpsMemoryDataAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides policy evaluation data from Policy Engine.
|
||||
/// </summary>
|
||||
public interface IPolicyDataProvider
|
||||
{
|
||||
Task<PolicyData?> GetPolicyEvaluationsAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string findingId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides provenance and attestation data from Attestor/EvidenceLocker.
|
||||
/// </summary>
|
||||
public interface IProvenanceDataProvider
|
||||
{
|
||||
Task<ProvenanceData?> GetProvenanceDataAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides fix availability data from Concelier/Package registries.
|
||||
/// </summary>
|
||||
public interface IFixDataProvider
|
||||
{
|
||||
Task<FixData?> GetFixDataAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
string? currentVersion,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides organizational context data.
|
||||
/// </summary>
|
||||
public interface IContextDataProvider
|
||||
{
|
||||
Task<ContextData?> GetContextDataAsync(
|
||||
string tenantId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Data Transfer Objects
|
||||
|
||||
/// <summary>
|
||||
/// VEX data from VexLens consensus engine.
|
||||
/// </summary>
|
||||
public sealed record VexData
|
||||
{
|
||||
public string? ConsensusStatus { get; init; }
|
||||
public string? ConsensusJustification { get; init; }
|
||||
public double? ConfidenceScore { get; init; }
|
||||
public string? ConsensusOutcome { get; init; }
|
||||
public string? LinksetId { get; init; }
|
||||
public IReadOnlyList<VexObservationData>? Observations { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VexObservationData
|
||||
{
|
||||
public required string ObservationId { get; init; }
|
||||
public required string ProviderId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public double? ConfidenceScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM data from SBOM Service.
|
||||
/// </summary>
|
||||
public sealed record SbomData
|
||||
{
|
||||
public required string SbomDigest { get; init; }
|
||||
public int ComponentCount { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finding data from Scanner.
|
||||
/// </summary>
|
||||
public sealed record FindingData
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Id { get; init; }
|
||||
public string? Package { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public double? CvssScore { get; init; }
|
||||
public double? EpssScore { get; init; }
|
||||
public bool? Kev { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public DateTimeOffset? DetectedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability analysis data from Scanner.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityData
|
||||
{
|
||||
public string? Status { get; init; }
|
||||
public double? ConfidenceScore { get; init; }
|
||||
public int PathCount { get; init; }
|
||||
public IReadOnlyList<PathWitnessData>? PathWitnesses { get; init; }
|
||||
public ReachabilityGatesData? Gates { get; init; }
|
||||
public int? RuntimeHits { get; init; }
|
||||
public string? CallgraphDigest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PathWitnessData
|
||||
{
|
||||
public required string WitnessId { get; init; }
|
||||
public string? Entrypoint { get; init; }
|
||||
public string? Sink { get; init; }
|
||||
public int? PathLength { get; init; }
|
||||
public IReadOnlyList<string>? Guards { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReachabilityGatesData
|
||||
{
|
||||
public bool? Reachable { get; init; }
|
||||
public bool? ConfigActivated { get; init; }
|
||||
public bool? RunningUser { get; init; }
|
||||
public int? GateClass { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binary patch detection data from Feedser.
|
||||
/// </summary>
|
||||
public sealed record BinaryPatchData
|
||||
{
|
||||
public bool Detected { get; init; }
|
||||
public string? ProofId { get; init; }
|
||||
public string? MatchMethod { get; init; }
|
||||
public double? Similarity { get; init; }
|
||||
public double? Confidence { get; init; }
|
||||
public IReadOnlyList<string>? PatchedSymbols { get; init; }
|
||||
public string? DistroAdvisory { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpsMemory historical data.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryData
|
||||
{
|
||||
public IReadOnlyList<SimilarDecisionData>? SimilarDecisions { get; init; }
|
||||
public IReadOnlyList<PlaybookData>? ApplicablePlaybooks { get; init; }
|
||||
public IReadOnlyList<KnownIssueData>? KnownIssues { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SimilarDecisionData
|
||||
{
|
||||
public required string RecordId { get; init; }
|
||||
public required double Similarity { get; init; }
|
||||
public string? Decision { get; init; }
|
||||
public string? Outcome { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PlaybookData
|
||||
{
|
||||
public required string PlaybookId { get; init; }
|
||||
public required string Tactic { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
public sealed record KnownIssueData
|
||||
{
|
||||
public required string IssueId { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? Resolution { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation data from Policy Engine.
|
||||
/// </summary>
|
||||
public sealed record PolicyData
|
||||
{
|
||||
public IReadOnlyList<PolicyEvaluationData>? Evaluations { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PolicyEvaluationData
|
||||
{
|
||||
public required string PolicyId { get; init; }
|
||||
public required string Decision { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public string? K4Position { get; init; }
|
||||
public string? EvaluationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provenance data from Attestor/EvidenceLocker.
|
||||
/// </summary>
|
||||
public sealed record ProvenanceData
|
||||
{
|
||||
public AttestationData? SbomAttestation { get; init; }
|
||||
public BuildProvenanceData? BuildProvenance { get; init; }
|
||||
public RekorEntryData? RekorEntry { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AttestationData
|
||||
{
|
||||
public string? DsseDigest { get; init; }
|
||||
public string? PredicateType { get; init; }
|
||||
public bool? SignatureValid { get; init; }
|
||||
public string? SignerKeyId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BuildProvenanceData
|
||||
{
|
||||
public string? DsseDigest { get; init; }
|
||||
public string? Builder { get; init; }
|
||||
public string? SourceRepo { get; init; }
|
||||
public string? SourceCommit { get; init; }
|
||||
public int? SlsaLevel { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RekorEntryData
|
||||
{
|
||||
public string? Uuid { get; init; }
|
||||
public long? LogIndex { get; init; }
|
||||
public DateTimeOffset? IntegratedTime { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix availability data.
|
||||
/// </summary>
|
||||
public sealed record FixData
|
||||
{
|
||||
public IReadOnlyList<UpgradeFixData>? Upgrades { get; init; }
|
||||
public DistroBackportData? DistroBackport { get; init; }
|
||||
public IReadOnlyList<ConfigFixData>? ConfigFixes { get; init; }
|
||||
public IReadOnlyList<ContainmentFixData>? Containment { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UpgradeFixData
|
||||
{
|
||||
public required string Version { get; init; }
|
||||
public DateTimeOffset? ReleaseDate { get; init; }
|
||||
public bool? BreakingChanges { get; init; }
|
||||
public string? Changelog { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DistroBackportData
|
||||
{
|
||||
public bool Available { get; init; }
|
||||
public string? Advisory { get; init; }
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ConfigFixData
|
||||
{
|
||||
public required string Option { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public string? Impact { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ContainmentFixData
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? Snippet { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Organizational context data.
|
||||
/// </summary>
|
||||
public sealed record ContextData
|
||||
{
|
||||
public string? TenantId { get; init; }
|
||||
public int? SlaDays { get; init; }
|
||||
public string? MaintenanceWindow { get; init; }
|
||||
public string? RiskAppetite { get; init; }
|
||||
public bool? AutoUpgradeAllowed { get; init; }
|
||||
public bool? ApprovalRequired { get; init; }
|
||||
public IReadOnlyList<string>? RequiredApprovers { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,638 @@
|
||||
// <copyright file="EvidenceBundleAssembler.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Chat.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Assembly;
|
||||
|
||||
/// <summary>
|
||||
/// Assembles evidence bundles from Stella platform data sources.
|
||||
/// Integrates with Scanner, VexLens, SBOM Service, OpsMemory, and Policy Engine.
|
||||
/// </summary>
|
||||
internal sealed class EvidenceBundleAssembler : IEvidenceBundleAssembler
|
||||
{
|
||||
private readonly IVexDataProvider _vexProvider;
|
||||
private readonly ISbomDataProvider _sbomProvider;
|
||||
private readonly IReachabilityDataProvider _reachabilityProvider;
|
||||
private readonly IBinaryPatchDataProvider _binaryPatchProvider;
|
||||
private readonly IOpsMemoryDataProvider _opsMemoryProvider;
|
||||
private readonly IPolicyDataProvider _policyProvider;
|
||||
private readonly IProvenanceDataProvider _provenanceProvider;
|
||||
private readonly IFixDataProvider _fixProvider;
|
||||
private readonly IContextDataProvider _contextProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<EvidenceBundleAssembler> _logger;
|
||||
|
||||
private const string EngineVersionName = "AdvisoryChatBundleAssembler";
|
||||
private const string EngineVersionNumber = "1.0.0";
|
||||
|
||||
public EvidenceBundleAssembler(
|
||||
IVexDataProvider vexProvider,
|
||||
ISbomDataProvider sbomProvider,
|
||||
IReachabilityDataProvider reachabilityProvider,
|
||||
IBinaryPatchDataProvider binaryPatchProvider,
|
||||
IOpsMemoryDataProvider opsMemoryProvider,
|
||||
IPolicyDataProvider policyProvider,
|
||||
IProvenanceDataProvider provenanceProvider,
|
||||
IFixDataProvider fixProvider,
|
||||
IContextDataProvider contextProvider,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<EvidenceBundleAssembler> logger)
|
||||
{
|
||||
_vexProvider = vexProvider ?? throw new ArgumentNullException(nameof(vexProvider));
|
||||
_sbomProvider = sbomProvider ?? throw new ArgumentNullException(nameof(sbomProvider));
|
||||
_reachabilityProvider = reachabilityProvider ?? throw new ArgumentNullException(nameof(reachabilityProvider));
|
||||
_binaryPatchProvider = binaryPatchProvider ?? throw new ArgumentNullException(nameof(binaryPatchProvider));
|
||||
_opsMemoryProvider = opsMemoryProvider ?? throw new ArgumentNullException(nameof(opsMemoryProvider));
|
||||
_policyProvider = policyProvider ?? throw new ArgumentNullException(nameof(policyProvider));
|
||||
_provenanceProvider = provenanceProvider ?? throw new ArgumentNullException(nameof(provenanceProvider));
|
||||
_fixProvider = fixProvider ?? throw new ArgumentNullException(nameof(fixProvider));
|
||||
_contextProvider = contextProvider ?? throw new ArgumentNullException(nameof(contextProvider));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EvidenceBundleAssemblyResult> AssembleAsync(
|
||||
EvidenceBundleAssemblyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var warnings = new List<string>();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Assembling evidence bundle for artifact {ArtifactDigest} finding {FindingId} in {Environment}",
|
||||
request.ArtifactDigest, request.FindingId, request.Environment);
|
||||
|
||||
try
|
||||
{
|
||||
// Assemble components in parallel where possible
|
||||
var assembledAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Phase 1: Core data (sequential - needed for subsequent lookups)
|
||||
var sbomData = await _sbomProvider.GetSbomDataAsync(
|
||||
request.TenantId, request.ArtifactDigest, cancellationToken);
|
||||
|
||||
if (sbomData is null)
|
||||
{
|
||||
return CreateFailure($"SBOM not found for artifact {request.ArtifactDigest}");
|
||||
}
|
||||
|
||||
var findingData = await _sbomProvider.GetFindingDataAsync(
|
||||
request.TenantId, request.ArtifactDigest, request.FindingId, request.PackagePurl, cancellationToken);
|
||||
|
||||
if (findingData is null)
|
||||
{
|
||||
return CreateFailure($"Finding {request.FindingId} not found in artifact {request.ArtifactDigest}");
|
||||
}
|
||||
|
||||
// Phase 2: Parallel data retrieval
|
||||
var vexTask = _vexProvider.GetVexDataAsync(
|
||||
request.TenantId, request.FindingId, findingData.Package, cancellationToken);
|
||||
|
||||
var policyTask = _policyProvider.GetPolicyEvaluationsAsync(
|
||||
request.TenantId, request.ArtifactDigest, request.FindingId, request.Environment, cancellationToken);
|
||||
|
||||
var provenanceTask = _provenanceProvider.GetProvenanceDataAsync(
|
||||
request.TenantId, request.ArtifactDigest, cancellationToken);
|
||||
|
||||
var fixTask = _fixProvider.GetFixDataAsync(
|
||||
request.TenantId, request.FindingId, findingData.Package, findingData.Version, cancellationToken);
|
||||
|
||||
var contextTask = _contextProvider.GetContextDataAsync(
|
||||
request.TenantId, request.Environment, cancellationToken);
|
||||
|
||||
// Conditional parallel tasks
|
||||
Task<ReachabilityData?> reachabilityTask = request.IncludeReachability
|
||||
? _reachabilityProvider.GetReachabilityDataAsync(
|
||||
request.TenantId, request.ArtifactDigest, findingData.Package, request.FindingId, cancellationToken)
|
||||
: Task.FromResult<ReachabilityData?>(null);
|
||||
|
||||
Task<BinaryPatchData?> binaryPatchTask = request.IncludeBinaryPatch
|
||||
? _binaryPatchProvider.GetBinaryPatchDataAsync(
|
||||
request.TenantId, request.ArtifactDigest, findingData.Package, request.FindingId, cancellationToken)
|
||||
: Task.FromResult<BinaryPatchData?>(null);
|
||||
|
||||
Task<OpsMemoryData?> opsMemoryTask = request.IncludeOpsMemory
|
||||
? _opsMemoryProvider.GetOpsMemoryDataAsync(
|
||||
request.TenantId, request.FindingId, findingData.Package, cancellationToken)
|
||||
: Task.FromResult<OpsMemoryData?>(null);
|
||||
|
||||
await Task.WhenAll(
|
||||
vexTask, policyTask, provenanceTask, fixTask, contextTask,
|
||||
reachabilityTask, binaryPatchTask, opsMemoryTask);
|
||||
|
||||
var vexData = await vexTask;
|
||||
var policyData = await policyTask;
|
||||
var provenanceData = await provenanceTask;
|
||||
var fixData = await fixTask;
|
||||
var contextData = await contextTask;
|
||||
var reachabilityData = await reachabilityTask;
|
||||
var binaryPatchData = await binaryPatchTask;
|
||||
var opsMemoryData = await opsMemoryTask;
|
||||
|
||||
// Build the evidence bundle
|
||||
var artifact = BuildArtifact(request, sbomData);
|
||||
var finding = BuildFinding(findingData);
|
||||
var verdicts = BuildVerdicts(vexData, policyData);
|
||||
var reachability = BuildReachability(reachabilityData, binaryPatchData);
|
||||
var provenance = BuildProvenance(provenanceData);
|
||||
var fixes = BuildFixes(fixData);
|
||||
var context = BuildContext(contextData);
|
||||
var opsMemory = BuildOpsMemory(opsMemoryData);
|
||||
var engineVersion = BuildEngineVersion();
|
||||
|
||||
// Compute deterministic bundle ID
|
||||
var bundleId = ComputeBundleId(request.ArtifactDigest, request.FindingId, assembledAt);
|
||||
|
||||
var bundle = new AdvisoryChatEvidenceBundle
|
||||
{
|
||||
BundleId = bundleId,
|
||||
AssembledAt = assembledAt,
|
||||
Artifact = artifact,
|
||||
Finding = finding,
|
||||
Verdicts = verdicts,
|
||||
Reachability = reachability,
|
||||
Provenance = provenance,
|
||||
Fixes = fixes,
|
||||
Context = context,
|
||||
OpsMemory = opsMemory,
|
||||
EngineVersion = engineVersion
|
||||
};
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var diagnostics = new EvidenceBundleAssemblyDiagnostics
|
||||
{
|
||||
SbomComponentsFound = sbomData.ComponentCount,
|
||||
VexObservationsFound = vexData?.Observations?.Count ?? 0,
|
||||
ReachabilityPathsFound = reachabilityData?.PathCount ?? 0,
|
||||
BinaryPatchDetected = binaryPatchData?.Detected ?? false,
|
||||
OpsMemoryRecordsFound = opsMemoryData?.SimilarDecisions?.Count ?? 0,
|
||||
PolicyEvaluationsFound = policyData?.Evaluations?.Count ?? 0,
|
||||
AssemblyDurationMs = stopwatch.ElapsedMilliseconds,
|
||||
Warnings = warnings
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Evidence bundle {BundleId} assembled in {ElapsedMs}ms with {VexObs} VEX observations, {Paths} reachability paths",
|
||||
bundleId, stopwatch.ElapsedMilliseconds,
|
||||
diagnostics.VexObservationsFound, diagnostics.ReachabilityPathsFound);
|
||||
|
||||
return new EvidenceBundleAssemblyResult
|
||||
{
|
||||
Success = true,
|
||||
Bundle = bundle,
|
||||
Diagnostics = diagnostics
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to assemble evidence bundle for {FindingId} in {ArtifactDigest}",
|
||||
request.FindingId, request.ArtifactDigest);
|
||||
|
||||
return CreateFailure($"Assembly failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeBundleId(string artifactDigest, string findingId, DateTimeOffset assembledAt)
|
||||
{
|
||||
// Deterministic bundle ID: sha256(artifact.digest + finding.id + assembledAt)
|
||||
var input = $"{artifactDigest}|{findingId}|{assembledAt:O}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static EvidenceArtifact BuildArtifact(EvidenceBundleAssemblyRequest request, SbomData sbomData)
|
||||
{
|
||||
return new EvidenceArtifact
|
||||
{
|
||||
Image = request.ImageReference,
|
||||
Digest = request.ArtifactDigest,
|
||||
Environment = request.Environment,
|
||||
SbomDigest = sbomData.SbomDigest,
|
||||
Labels = sbomData.Labels?.ToImmutableDictionary(StringComparer.Ordinal)
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceFinding BuildFinding(FindingData data)
|
||||
{
|
||||
return new EvidenceFinding
|
||||
{
|
||||
Type = ParseFindingType(data.Type),
|
||||
Id = data.Id,
|
||||
Package = data.Package,
|
||||
Version = data.Version,
|
||||
Severity = ParseSeverity(data.Severity),
|
||||
CvssScore = data.CvssScore,
|
||||
EpssScore = data.EpssScore,
|
||||
Kev = data.Kev,
|
||||
Description = data.Description,
|
||||
DetectedAt = data.DetectedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceVerdicts? BuildVerdicts(VexData? vexData, PolicyData? policyData)
|
||||
{
|
||||
if (vexData is null && policyData is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
VexVerdict? vex = null;
|
||||
if (vexData is not null)
|
||||
{
|
||||
var observations = vexData.Observations?
|
||||
.OrderBy(o => o.ProviderId, StringComparer.Ordinal)
|
||||
.Select(o => new VexObservation
|
||||
{
|
||||
ObservationId = o.ObservationId,
|
||||
ProviderId = o.ProviderId.ToLowerInvariant(),
|
||||
Status = ParseVexStatus(o.Status),
|
||||
Justification = ParseVexJustification(o.Justification),
|
||||
ConfidenceScore = o.ConfidenceScore
|
||||
})
|
||||
.ToImmutableArray() ?? ImmutableArray<VexObservation>.Empty;
|
||||
|
||||
vex = new VexVerdict
|
||||
{
|
||||
Status = ParseVexStatus(vexData.ConsensusStatus),
|
||||
Justification = ParseVexJustification(vexData.ConsensusJustification),
|
||||
ConfidenceScore = vexData.ConfidenceScore,
|
||||
ConsensusOutcome = ParseConsensusOutcome(vexData.ConsensusOutcome),
|
||||
Observations = observations,
|
||||
LinksetId = vexData.LinksetId
|
||||
};
|
||||
}
|
||||
|
||||
var policyVerdicts = policyData?.Evaluations?
|
||||
.OrderBy(e => e.PolicyId, StringComparer.Ordinal)
|
||||
.Select(e => new PolicyVerdict
|
||||
{
|
||||
PolicyId = e.PolicyId,
|
||||
Decision = ParsePolicyDecision(e.Decision),
|
||||
Reason = e.Reason,
|
||||
K4Position = e.K4Position,
|
||||
EvaluationId = e.EvaluationId
|
||||
})
|
||||
.ToImmutableArray() ?? ImmutableArray<PolicyVerdict>.Empty;
|
||||
|
||||
return new EvidenceVerdicts
|
||||
{
|
||||
Vex = vex,
|
||||
Policy = policyVerdicts
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceReachability? BuildReachability(ReachabilityData? reachabilityData, BinaryPatchData? binaryPatchData)
|
||||
{
|
||||
if (reachabilityData is null && binaryPatchData is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var pathWitnesses = reachabilityData?.PathWitnesses?
|
||||
.OrderBy(p => p.WitnessId, StringComparer.Ordinal)
|
||||
.Select(p => new PathWitness
|
||||
{
|
||||
WitnessId = p.WitnessId,
|
||||
Entrypoint = p.Entrypoint,
|
||||
Sink = p.Sink,
|
||||
PathLength = p.PathLength,
|
||||
Guards = p.Guards?.ToImmutableArray() ?? ImmutableArray<string>.Empty
|
||||
})
|
||||
.ToImmutableArray() ?? ImmutableArray<PathWitness>.Empty;
|
||||
|
||||
ReachabilityGates? gates = null;
|
||||
if (reachabilityData?.Gates is not null)
|
||||
{
|
||||
gates = new ReachabilityGates
|
||||
{
|
||||
Reachable = reachabilityData.Gates.Reachable,
|
||||
ConfigActivated = reachabilityData.Gates.ConfigActivated,
|
||||
RunningUser = reachabilityData.Gates.RunningUser,
|
||||
GateClass = reachabilityData.Gates.GateClass
|
||||
};
|
||||
}
|
||||
|
||||
BinaryPatchEvidence? binaryPatch = null;
|
||||
if (binaryPatchData is not null)
|
||||
{
|
||||
binaryPatch = new BinaryPatchEvidence
|
||||
{
|
||||
Detected = binaryPatchData.Detected,
|
||||
ProofId = binaryPatchData.ProofId,
|
||||
MatchMethod = ParseMatchMethod(binaryPatchData.MatchMethod),
|
||||
Similarity = binaryPatchData.Similarity,
|
||||
Confidence = binaryPatchData.Confidence,
|
||||
PatchedSymbols = binaryPatchData.PatchedSymbols?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
|
||||
DistroAdvisory = binaryPatchData.DistroAdvisory
|
||||
};
|
||||
}
|
||||
|
||||
return new EvidenceReachability
|
||||
{
|
||||
Status = ParseReachabilityStatus(reachabilityData?.Status),
|
||||
ConfidenceScore = reachabilityData?.ConfidenceScore,
|
||||
CallgraphPaths = reachabilityData?.PathCount,
|
||||
PathWitnesses = pathWitnesses,
|
||||
Gates = gates,
|
||||
RuntimeHits = reachabilityData?.RuntimeHits,
|
||||
CallgraphDigest = reachabilityData?.CallgraphDigest,
|
||||
BinaryPatch = binaryPatch
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceProvenance? BuildProvenance(ProvenanceData? data)
|
||||
{
|
||||
if (data is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
AttestationReference? sbomAttestation = null;
|
||||
if (data.SbomAttestation is not null)
|
||||
{
|
||||
sbomAttestation = new AttestationReference
|
||||
{
|
||||
DsseDigest = data.SbomAttestation.DsseDigest,
|
||||
PredicateType = data.SbomAttestation.PredicateType,
|
||||
SignatureValid = data.SbomAttestation.SignatureValid,
|
||||
SignerKeyId = data.SbomAttestation.SignerKeyId
|
||||
};
|
||||
}
|
||||
|
||||
BuildProvenance? buildProvenance = null;
|
||||
if (data.BuildProvenance is not null)
|
||||
{
|
||||
buildProvenance = new BuildProvenance
|
||||
{
|
||||
DsseDigest = data.BuildProvenance.DsseDigest,
|
||||
Builder = data.BuildProvenance.Builder,
|
||||
SourceRepo = data.BuildProvenance.SourceRepo,
|
||||
SourceCommit = data.BuildProvenance.SourceCommit,
|
||||
SlsaLevel = data.BuildProvenance.SlsaLevel
|
||||
};
|
||||
}
|
||||
|
||||
RekorEntry? rekorEntry = null;
|
||||
if (data.RekorEntry is not null)
|
||||
{
|
||||
rekorEntry = new RekorEntry
|
||||
{
|
||||
Uuid = data.RekorEntry.Uuid,
|
||||
LogIndex = data.RekorEntry.LogIndex,
|
||||
IntegratedTime = data.RekorEntry.IntegratedTime
|
||||
};
|
||||
}
|
||||
|
||||
return new EvidenceProvenance
|
||||
{
|
||||
SbomAttestation = sbomAttestation,
|
||||
BuildProvenance = buildProvenance,
|
||||
RekorEntry = rekorEntry
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceFixes? BuildFixes(FixData? data)
|
||||
{
|
||||
if (data is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var upgrades = data.Upgrades?
|
||||
.OrderBy(u => u.Version, StringComparer.Ordinal)
|
||||
.Select(u => new UpgradeFix
|
||||
{
|
||||
Version = u.Version,
|
||||
ReleaseDate = u.ReleaseDate,
|
||||
BreakingChanges = u.BreakingChanges,
|
||||
Changelog = u.Changelog
|
||||
})
|
||||
.ToImmutableArray() ?? ImmutableArray<UpgradeFix>.Empty;
|
||||
|
||||
DistroBackport? distroBackport = null;
|
||||
if (data.DistroBackport is not null)
|
||||
{
|
||||
distroBackport = new DistroBackport
|
||||
{
|
||||
Available = data.DistroBackport.Available,
|
||||
Advisory = data.DistroBackport.Advisory,
|
||||
Version = data.DistroBackport.Version
|
||||
};
|
||||
}
|
||||
|
||||
var configFixes = data.ConfigFixes?
|
||||
.Select(c => new ConfigFix
|
||||
{
|
||||
Option = c.Option,
|
||||
Description = c.Description,
|
||||
Impact = c.Impact
|
||||
})
|
||||
.ToImmutableArray() ?? ImmutableArray<ConfigFix>.Empty;
|
||||
|
||||
var containment = data.Containment?
|
||||
.Select(c => new ContainmentFix
|
||||
{
|
||||
Type = ParseContainmentType(c.Type),
|
||||
Description = c.Description,
|
||||
Snippet = c.Snippet
|
||||
})
|
||||
.ToImmutableArray() ?? ImmutableArray<ContainmentFix>.Empty;
|
||||
|
||||
return new EvidenceFixes
|
||||
{
|
||||
Upgrade = upgrades,
|
||||
DistroBackport = distroBackport,
|
||||
Config = configFixes,
|
||||
Containment = containment
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceContext? BuildContext(ContextData? data)
|
||||
{
|
||||
if (data is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new EvidenceContext
|
||||
{
|
||||
TenantId = data.TenantId,
|
||||
SlaDays = data.SlaDays,
|
||||
MaintenanceWindow = data.MaintenanceWindow,
|
||||
RiskAppetite = ParseRiskAppetite(data.RiskAppetite),
|
||||
AutoUpgradeAllowed = data.AutoUpgradeAllowed,
|
||||
ApprovalRequired = data.ApprovalRequired,
|
||||
RequiredApprovers = data.RequiredApprovers?.ToImmutableArray() ?? ImmutableArray<string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceOpsMemory? BuildOpsMemory(OpsMemoryData? data)
|
||||
{
|
||||
if (data is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var similarDecisions = data.SimilarDecisions?
|
||||
.OrderByDescending(d => d.Similarity)
|
||||
.Select(d => new SimilarDecision
|
||||
{
|
||||
RecordId = d.RecordId,
|
||||
Similarity = d.Similarity,
|
||||
Decision = d.Decision,
|
||||
Outcome = d.Outcome,
|
||||
Timestamp = d.Timestamp
|
||||
})
|
||||
.ToImmutableArray() ?? ImmutableArray<SimilarDecision>.Empty;
|
||||
|
||||
var playbooks = data.ApplicablePlaybooks?
|
||||
.Select(p => new ApplicablePlaybook
|
||||
{
|
||||
PlaybookId = p.PlaybookId,
|
||||
Tactic = p.Tactic,
|
||||
Description = p.Description
|
||||
})
|
||||
.ToImmutableArray() ?? ImmutableArray<ApplicablePlaybook>.Empty;
|
||||
|
||||
var knownIssues = data.KnownIssues?
|
||||
.Select(i => new KnownIssue
|
||||
{
|
||||
IssueId = i.IssueId,
|
||||
Title = i.Title,
|
||||
Resolution = i.Resolution,
|
||||
ResolvedAt = i.ResolvedAt
|
||||
})
|
||||
.ToImmutableArray() ?? ImmutableArray<KnownIssue>.Empty;
|
||||
|
||||
return new EvidenceOpsMemory
|
||||
{
|
||||
SimilarDecisions = similarDecisions,
|
||||
ApplicablePlaybooks = playbooks,
|
||||
KnownIssues = knownIssues
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceEngineVersion BuildEngineVersion()
|
||||
{
|
||||
return new EvidenceEngineVersion
|
||||
{
|
||||
Name = EngineVersionName,
|
||||
Version = EngineVersionNumber,
|
||||
SourceDigest = null // Set during build
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceBundleAssemblyResult CreateFailure(string error)
|
||||
{
|
||||
return new EvidenceBundleAssemblyResult
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
// Enum parsing helpers
|
||||
private static EvidenceFindingType ParseFindingType(string? type) => type?.ToUpperInvariant() switch
|
||||
{
|
||||
"CVE" => EvidenceFindingType.Cve,
|
||||
"GHSA" => EvidenceFindingType.Ghsa,
|
||||
"POLICY_VIOLATION" => EvidenceFindingType.PolicyViolation,
|
||||
"SECRET_EXPOSURE" => EvidenceFindingType.SecretExposure,
|
||||
"MISCONFIGURATION" => EvidenceFindingType.Misconfiguration,
|
||||
_ => EvidenceFindingType.Cve
|
||||
};
|
||||
|
||||
private static EvidenceSeverity ParseSeverity(string? severity) => severity?.ToUpperInvariant() switch
|
||||
{
|
||||
"NONE" => EvidenceSeverity.None,
|
||||
"LOW" => EvidenceSeverity.Low,
|
||||
"MEDIUM" => EvidenceSeverity.Medium,
|
||||
"HIGH" => EvidenceSeverity.High,
|
||||
"CRITICAL" => EvidenceSeverity.Critical,
|
||||
_ => EvidenceSeverity.Unknown
|
||||
};
|
||||
|
||||
private static VexStatus ParseVexStatus(string? status) => status?.ToUpperInvariant() switch
|
||||
{
|
||||
"AFFECTED" => VexStatus.Affected,
|
||||
"NOT_AFFECTED" => VexStatus.NotAffected,
|
||||
"FIXED" => VexStatus.Fixed,
|
||||
"UNDER_INVESTIGATION" => VexStatus.UnderInvestigation,
|
||||
_ => VexStatus.Unknown
|
||||
};
|
||||
|
||||
private static VexJustification? ParseVexJustification(string? justification) => justification?.ToUpperInvariant() switch
|
||||
{
|
||||
"COMPONENT_NOT_PRESENT" => VexJustification.ComponentNotPresent,
|
||||
"VULNERABLE_CODE_NOT_PRESENT" => VexJustification.VulnerableCodeNotPresent,
|
||||
"VULNERABLE_CODE_NOT_IN_EXECUTE_PATH" => VexJustification.VulnerableCodeNotInExecutePath,
|
||||
"VULNERABLE_CODE_CANNOT_BE_CONTROLLED_BY_ADVERSARY" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
|
||||
"INLINE_MITIGATIONS_ALREADY_EXIST" => VexJustification.InlineMitigationsAlreadyExist,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static VexConsensusOutcome? ParseConsensusOutcome(string? outcome) => outcome?.ToUpperInvariant() switch
|
||||
{
|
||||
"UNANIMOUS" => VexConsensusOutcome.Unanimous,
|
||||
"MAJORITY" => VexConsensusOutcome.Majority,
|
||||
"PLURALITY" => VexConsensusOutcome.Plurality,
|
||||
"CONFLICT_RESOLVED" => VexConsensusOutcome.ConflictResolved,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static PolicyDecision ParsePolicyDecision(string? decision) => decision?.ToUpperInvariant() switch
|
||||
{
|
||||
"ALLOW" => PolicyDecision.Allow,
|
||||
"WARN" => PolicyDecision.Warn,
|
||||
"BLOCK" => PolicyDecision.Block,
|
||||
_ => PolicyDecision.Warn
|
||||
};
|
||||
|
||||
private static ReachabilityStatus ParseReachabilityStatus(string? status) => status?.ToUpperInvariant() switch
|
||||
{
|
||||
"REACHABLE" => ReachabilityStatus.Reachable,
|
||||
"UNREACHABLE" => ReachabilityStatus.Unreachable,
|
||||
"CONDITIONAL" => ReachabilityStatus.Conditional,
|
||||
_ => ReachabilityStatus.Unknown
|
||||
};
|
||||
|
||||
private static BinaryMatchMethod? ParseMatchMethod(string? method) => method?.ToUpperInvariant() switch
|
||||
{
|
||||
"TLSH" => BinaryMatchMethod.Tlsh,
|
||||
"CFG_HASH" => BinaryMatchMethod.CfgHash,
|
||||
"INSTRUCTION_HASH" => BinaryMatchMethod.InstructionHash,
|
||||
"SYMBOL_HASH" => BinaryMatchMethod.SymbolHash,
|
||||
"SECTION_HASH" => BinaryMatchMethod.SectionHash,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static ContainmentType ParseContainmentType(string? type) => type?.ToUpperInvariant() switch
|
||||
{
|
||||
"WAF_RULE" => ContainmentType.WafRule,
|
||||
"SECCOMP" => ContainmentType.Seccomp,
|
||||
"APPARMOR" => ContainmentType.Apparmor,
|
||||
"NETWORK_POLICY" => ContainmentType.NetworkPolicy,
|
||||
"ADMISSION_CONTROLLER" => ContainmentType.AdmissionController,
|
||||
_ => ContainmentType.WafRule
|
||||
};
|
||||
|
||||
private static RiskAppetite? ParseRiskAppetite(string? appetite) => appetite?.ToUpperInvariant() switch
|
||||
{
|
||||
"CONSERVATIVE" => RiskAppetite.Conservative,
|
||||
"MODERATE" => RiskAppetite.Moderate,
|
||||
"AGGRESSIVE" => RiskAppetite.Aggressive,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// <copyright file="IEvidenceBundleAssembler.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.AdvisoryAI.Chat.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Assembly;
|
||||
|
||||
/// <summary>
|
||||
/// Assembles evidence bundles from Stella platform data sources.
|
||||
/// No external data - only Stella objects (SBOM, VEX, Reachability, Binary Patches, etc.).
|
||||
/// </summary>
|
||||
public interface IEvidenceBundleAssembler
|
||||
{
|
||||
/// <summary>
|
||||
/// Assembles a complete evidence bundle for a finding in an artifact.
|
||||
/// </summary>
|
||||
/// <param name="request">Assembly request with artifact and finding identifiers.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Assembled evidence bundle with deterministic bundle ID.</returns>
|
||||
Task<EvidenceBundleAssemblyResult> AssembleAsync(
|
||||
EvidenceBundleAssemblyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to assemble an evidence bundle.
|
||||
/// </summary>
|
||||
public sealed record EvidenceBundleAssemblyRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant ID for multi-tenancy.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image digest (sha256:...).
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional image reference (registry/repo:tag).
|
||||
/// </summary>
|
||||
public string? ImageReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deployment environment (prod-eu1, staging, dev).
|
||||
/// </summary>
|
||||
public required string Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Finding identifier (CVE-YYYY-NNNNN, GHSA-..., policy ID).
|
||||
/// </summary>
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional package PURL to scope the finding.
|
||||
/// </summary>
|
||||
public string? PackagePurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include OpsMemory context.
|
||||
/// </summary>
|
||||
public bool IncludeOpsMemory { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include full reachability analysis.
|
||||
/// </summary>
|
||||
public bool IncludeReachability { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include binary patch detection.
|
||||
/// </summary>
|
||||
public bool IncludeBinaryPatch { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of evidence bundle assembly.
|
||||
/// </summary>
|
||||
public sealed record EvidenceBundleAssemblyResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether assembly succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Assembled evidence bundle (null if failed).
|
||||
/// </summary>
|
||||
public AdvisoryChatEvidenceBundle? Bundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if assembly failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Assembly diagnostics.
|
||||
/// </summary>
|
||||
public EvidenceBundleAssemblyDiagnostics? Diagnostics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assembly diagnostics for observability.
|
||||
/// </summary>
|
||||
public sealed record EvidenceBundleAssemblyDiagnostics
|
||||
{
|
||||
public int SbomComponentsFound { get; init; }
|
||||
public int VexObservationsFound { get; init; }
|
||||
public int ReachabilityPathsFound { get; init; }
|
||||
public bool BinaryPatchDetected { get; init; }
|
||||
public int OpsMemoryRecordsFound { get; init; }
|
||||
public int PolicyEvaluationsFound { get; init; }
|
||||
public long AssemblyDurationMs { get; init; }
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// <copyright file="BinaryPatchDataProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Assembly.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves binary patch detection data from BinaryIndex/Feedser.
|
||||
/// </summary>
|
||||
internal sealed class BinaryPatchDataProvider : IBinaryPatchDataProvider
|
||||
{
|
||||
private readonly IBinaryPatchClient _binaryPatchClient;
|
||||
private readonly ILogger<BinaryPatchDataProvider> _logger;
|
||||
|
||||
public BinaryPatchDataProvider(
|
||||
IBinaryPatchClient binaryPatchClient,
|
||||
ILogger<BinaryPatchDataProvider> logger)
|
||||
{
|
||||
_binaryPatchClient = binaryPatchClient ?? throw new ArgumentNullException(nameof(binaryPatchClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<BinaryPatchData?> GetBinaryPatchDataAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string? packagePurl,
|
||||
string vulnerabilityId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetching binary patch data for tenant {TenantId}, artifact {ArtifactDigest}, vulnerability {VulnerabilityId}",
|
||||
tenantId, TruncateDigest(artifactDigest), vulnerabilityId);
|
||||
|
||||
try
|
||||
{
|
||||
var detection = await _binaryPatchClient.DetectBackportAsync(
|
||||
tenantId,
|
||||
artifactDigest,
|
||||
packagePurl,
|
||||
vulnerabilityId,
|
||||
cancellationToken);
|
||||
|
||||
if (detection is null)
|
||||
{
|
||||
_logger.LogDebug("No binary patch detection for {VulnerabilityId}", vulnerabilityId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BinaryPatchData
|
||||
{
|
||||
Detected = detection.Detected,
|
||||
ProofId = detection.ProofId,
|
||||
MatchMethod = detection.MatchMethod,
|
||||
Similarity = detection.Similarity,
|
||||
Confidence = detection.Confidence,
|
||||
PatchedSymbols = detection.PatchedSymbols,
|
||||
DistroAdvisory = detection.DistroAdvisory
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to fetch binary patch data for {VulnerabilityId}, returning null",
|
||||
vulnerabilityId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest) =>
|
||||
digest.Length > 16 ? digest[..16] + "..." : digest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for binary patch detection.
|
||||
/// </summary>
|
||||
public interface IBinaryPatchClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects if a binary has been patched for a vulnerability.
|
||||
/// </summary>
|
||||
Task<BinaryPatchDetectionResult?> DetectBackportAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string? packagePurl,
|
||||
string vulnerabilityId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binary patch detection result.
|
||||
/// </summary>
|
||||
public sealed record BinaryPatchDetectionResult
|
||||
{
|
||||
public bool Detected { get; init; }
|
||||
public string? ProofId { get; init; }
|
||||
public string? MatchMethod { get; init; }
|
||||
public double? Similarity { get; init; }
|
||||
public double? Confidence { get; init; }
|
||||
public IReadOnlyList<string>? PatchedSymbols { get; init; }
|
||||
public string? DistroAdvisory { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IBinaryPatchClient.
|
||||
/// </summary>
|
||||
internal sealed class NullBinaryPatchClient : IBinaryPatchClient
|
||||
{
|
||||
public Task<BinaryPatchDetectionResult?> DetectBackportAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string? packagePurl,
|
||||
string vulnerabilityId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<BinaryPatchDetectionResult?>(null);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// <copyright file="ContextDataProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Assembly.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves organizational context data.
|
||||
/// </summary>
|
||||
internal sealed class ContextDataProvider : IContextDataProvider
|
||||
{
|
||||
private readonly IOrganizationContextClient _contextClient;
|
||||
private readonly ILogger<ContextDataProvider> _logger;
|
||||
|
||||
public ContextDataProvider(
|
||||
IOrganizationContextClient contextClient,
|
||||
ILogger<ContextDataProvider> logger)
|
||||
{
|
||||
_contextClient = contextClient ?? throw new ArgumentNullException(nameof(contextClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ContextData?> GetContextDataAsync(
|
||||
string tenantId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(environment);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetching context data for tenant {TenantId}, environment {Environment}",
|
||||
tenantId, environment);
|
||||
|
||||
try
|
||||
{
|
||||
var context = await _contextClient.GetOrganizationContextAsync(
|
||||
tenantId,
|
||||
environment,
|
||||
cancellationToken);
|
||||
|
||||
if (context is null)
|
||||
{
|
||||
_logger.LogDebug("No context data found for {TenantId}/{Environment}", tenantId, environment);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ContextData
|
||||
{
|
||||
TenantId = context.TenantId,
|
||||
SlaDays = context.SlaDays,
|
||||
MaintenanceWindow = context.MaintenanceWindow,
|
||||
RiskAppetite = context.RiskAppetite,
|
||||
AutoUpgradeAllowed = context.AutoUpgradeAllowed,
|
||||
ApprovalRequired = context.ApprovalRequired,
|
||||
RequiredApprovers = context.RequiredApprovers
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to fetch context data for {TenantId}/{Environment}, returning null",
|
||||
tenantId, environment);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for organization context.
|
||||
/// </summary>
|
||||
public interface IOrganizationContextClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets organization context for an environment.
|
||||
/// </summary>
|
||||
Task<OrganizationContextResult?> GetOrganizationContextAsync(
|
||||
string tenantId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Organization context result.
|
||||
/// </summary>
|
||||
public sealed record OrganizationContextResult
|
||||
{
|
||||
public string? TenantId { get; init; }
|
||||
public int? SlaDays { get; init; }
|
||||
public string? MaintenanceWindow { get; init; }
|
||||
public string? RiskAppetite { get; init; }
|
||||
public bool? AutoUpgradeAllowed { get; init; }
|
||||
public bool? ApprovalRequired { get; init; }
|
||||
public IReadOnlyList<string>? RequiredApprovers { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IOrganizationContextClient.
|
||||
/// </summary>
|
||||
internal sealed class NullOrganizationContextClient : IOrganizationContextClient
|
||||
{
|
||||
public Task<OrganizationContextResult?> GetOrganizationContextAsync(
|
||||
string tenantId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<OrganizationContextResult?>(null);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// <copyright file="FixDataProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Assembly.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves fix availability data from Concelier/Package registries.
|
||||
/// </summary>
|
||||
internal sealed class FixDataProvider : IFixDataProvider
|
||||
{
|
||||
private readonly IFixAvailabilityClient _fixClient;
|
||||
private readonly ILogger<FixDataProvider> _logger;
|
||||
|
||||
public FixDataProvider(
|
||||
IFixAvailabilityClient fixClient,
|
||||
ILogger<FixDataProvider> logger)
|
||||
{
|
||||
_fixClient = fixClient ?? throw new ArgumentNullException(nameof(fixClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<FixData?> GetFixDataAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
string? currentVersion,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetching fix data for tenant {TenantId}, vulnerability {VulnerabilityId}, package {Package}",
|
||||
tenantId, vulnerabilityId, packagePurl ?? "(unknown)");
|
||||
|
||||
try
|
||||
{
|
||||
var fixes = await _fixClient.GetFixOptionsAsync(
|
||||
tenantId,
|
||||
vulnerabilityId,
|
||||
packagePurl,
|
||||
currentVersion,
|
||||
cancellationToken);
|
||||
|
||||
if (fixes is null)
|
||||
{
|
||||
_logger.LogDebug("No fix data found for {VulnerabilityId}", vulnerabilityId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var upgrades = fixes.Upgrades?
|
||||
.Select(u => new UpgradeFixData
|
||||
{
|
||||
Version = u.Version,
|
||||
ReleaseDate = u.ReleaseDate,
|
||||
BreakingChanges = u.BreakingChanges,
|
||||
Changelog = u.Changelog
|
||||
})
|
||||
.ToList();
|
||||
|
||||
DistroBackportData? distroBackport = null;
|
||||
if (fixes.DistroBackport is not null)
|
||||
{
|
||||
distroBackport = new DistroBackportData
|
||||
{
|
||||
Available = fixes.DistroBackport.Available,
|
||||
Advisory = fixes.DistroBackport.Advisory,
|
||||
Version = fixes.DistroBackport.Version
|
||||
};
|
||||
}
|
||||
|
||||
var configFixes = fixes.ConfigFixes?
|
||||
.Select(c => new ConfigFixData
|
||||
{
|
||||
Option = c.Option,
|
||||
Description = c.Description,
|
||||
Impact = c.Impact
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var containment = fixes.Containment?
|
||||
.Select(c => new ContainmentFixData
|
||||
{
|
||||
Type = c.Type,
|
||||
Description = c.Description,
|
||||
Snippet = c.Snippet
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new FixData
|
||||
{
|
||||
Upgrades = upgrades,
|
||||
DistroBackport = distroBackport,
|
||||
ConfigFixes = configFixes,
|
||||
Containment = containment
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to fetch fix data for {VulnerabilityId}, returning null",
|
||||
vulnerabilityId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for fix availability.
|
||||
/// </summary>
|
||||
public interface IFixAvailabilityClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets available fix options for a vulnerability.
|
||||
/// </summary>
|
||||
Task<FixOptionsResult?> GetFixOptionsAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
string? currentVersion,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix options result.
|
||||
/// </summary>
|
||||
public sealed record FixOptionsResult
|
||||
{
|
||||
public IReadOnlyList<UpgradeFixResult>? Upgrades { get; init; }
|
||||
public DistroBackportResult? DistroBackport { get; init; }
|
||||
public IReadOnlyList<ConfigFixResult>? ConfigFixes { get; init; }
|
||||
public IReadOnlyList<ContainmentResult>? Containment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upgrade fix result.
|
||||
/// </summary>
|
||||
public sealed record UpgradeFixResult
|
||||
{
|
||||
public required string Version { get; init; }
|
||||
public DateTimeOffset? ReleaseDate { get; init; }
|
||||
public bool? BreakingChanges { get; init; }
|
||||
public string? Changelog { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distro backport result.
|
||||
/// </summary>
|
||||
public sealed record DistroBackportResult
|
||||
{
|
||||
public bool Available { get; init; }
|
||||
public string? Advisory { get; init; }
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Config fix result.
|
||||
/// </summary>
|
||||
public sealed record ConfigFixResult
|
||||
{
|
||||
public required string Option { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public string? Impact { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Containment result.
|
||||
/// </summary>
|
||||
public sealed record ContainmentResult
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? Snippet { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IFixAvailabilityClient.
|
||||
/// </summary>
|
||||
internal sealed class NullFixAvailabilityClient : IFixAvailabilityClient
|
||||
{
|
||||
public Task<FixOptionsResult?> GetFixOptionsAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
string? currentVersion,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<FixOptionsResult?>(null);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// <copyright file="OpsMemoryDataProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Assembly.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves historical decision data from OpsMemory.
|
||||
/// </summary>
|
||||
internal sealed class OpsMemoryDataProvider : IOpsMemoryDataProvider
|
||||
{
|
||||
private readonly IOpsMemoryClient _opsMemoryClient;
|
||||
private readonly ILogger<OpsMemoryDataProvider> _logger;
|
||||
|
||||
public OpsMemoryDataProvider(
|
||||
IOpsMemoryClient opsMemoryClient,
|
||||
ILogger<OpsMemoryDataProvider> logger)
|
||||
{
|
||||
_opsMemoryClient = opsMemoryClient ?? throw new ArgumentNullException(nameof(opsMemoryClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<OpsMemoryData?> GetOpsMemoryDataAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetching OpsMemory data for tenant {TenantId}, vulnerability {VulnerabilityId}",
|
||||
tenantId, vulnerabilityId);
|
||||
|
||||
try
|
||||
{
|
||||
// Fetch similar decisions, playbooks, and known issues in parallel
|
||||
var similarDecisionsTask = _opsMemoryClient.GetSimilarDecisionsAsync(
|
||||
tenantId,
|
||||
vulnerabilityId,
|
||||
packagePurl,
|
||||
maxResults: 5,
|
||||
cancellationToken);
|
||||
|
||||
var playbooksTask = _opsMemoryClient.GetApplicablePlaybooksAsync(
|
||||
tenantId,
|
||||
vulnerabilityId,
|
||||
cancellationToken);
|
||||
|
||||
var knownIssuesTask = _opsMemoryClient.GetKnownIssuesAsync(
|
||||
tenantId,
|
||||
vulnerabilityId,
|
||||
packagePurl,
|
||||
cancellationToken);
|
||||
|
||||
await Task.WhenAll(similarDecisionsTask, playbooksTask, knownIssuesTask);
|
||||
|
||||
var similarDecisions = await similarDecisionsTask;
|
||||
var playbooks = await playbooksTask;
|
||||
var knownIssues = await knownIssuesTask;
|
||||
|
||||
// Return null if no data found
|
||||
if ((similarDecisions is null || similarDecisions.Count == 0) &&
|
||||
(playbooks is null || playbooks.Count == 0) &&
|
||||
(knownIssues is null || knownIssues.Count == 0))
|
||||
{
|
||||
_logger.LogDebug("No OpsMemory data found for {VulnerabilityId}", vulnerabilityId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new OpsMemoryData
|
||||
{
|
||||
SimilarDecisions = similarDecisions?
|
||||
.Select(d => new SimilarDecisionData
|
||||
{
|
||||
RecordId = d.RecordId,
|
||||
Similarity = d.Similarity,
|
||||
Decision = d.Decision,
|
||||
Outcome = d.Outcome,
|
||||
Timestamp = d.Timestamp
|
||||
})
|
||||
.ToList(),
|
||||
ApplicablePlaybooks = playbooks?
|
||||
.Select(p => new PlaybookData
|
||||
{
|
||||
PlaybookId = p.PlaybookId,
|
||||
Tactic = p.Tactic,
|
||||
Description = p.Description
|
||||
})
|
||||
.ToList(),
|
||||
KnownIssues = knownIssues?
|
||||
.Select(i => new KnownIssueData
|
||||
{
|
||||
IssueId = i.IssueId,
|
||||
Title = i.Title,
|
||||
Resolution = i.Resolution,
|
||||
ResolvedAt = i.ResolvedAt
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to fetch OpsMemory data for {VulnerabilityId}, returning null",
|
||||
vulnerabilityId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for OpsMemory service.
|
||||
/// </summary>
|
||||
public interface IOpsMemoryClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets similar historical decisions.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SimilarDecisionResult>?> GetSimilarDecisionsAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
int maxResults,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets applicable playbooks for a vulnerability.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PlaybookResult>?> GetApplicablePlaybooksAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets known issues related to a vulnerability.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<KnownIssueResult>?> GetKnownIssuesAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Similar decision result from OpsMemory.
|
||||
/// </summary>
|
||||
public sealed record SimilarDecisionResult
|
||||
{
|
||||
public required string RecordId { get; init; }
|
||||
public required double Similarity { get; init; }
|
||||
public string? Decision { get; init; }
|
||||
public string? Outcome { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Playbook result from OpsMemory.
|
||||
/// </summary>
|
||||
public sealed record PlaybookResult
|
||||
{
|
||||
public required string PlaybookId { get; init; }
|
||||
public required string Tactic { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Known issue result from OpsMemory.
|
||||
/// </summary>
|
||||
public sealed record KnownIssueResult
|
||||
{
|
||||
public required string IssueId { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? Resolution { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IOpsMemoryClient.
|
||||
/// </summary>
|
||||
internal sealed class NullOpsMemoryClient : IOpsMemoryClient
|
||||
{
|
||||
public Task<IReadOnlyList<SimilarDecisionResult>?> GetSimilarDecisionsAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
int maxResults,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<SimilarDecisionResult>?>(null);
|
||||
|
||||
public Task<IReadOnlyList<PlaybookResult>?> GetApplicablePlaybooksAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<PlaybookResult>?>(null);
|
||||
|
||||
public Task<IReadOnlyList<KnownIssueResult>?> GetKnownIssuesAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<KnownIssueResult>?>(null);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// <copyright file="PolicyDataProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Assembly.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves policy evaluation data from Policy Engine.
|
||||
/// </summary>
|
||||
internal sealed class PolicyDataProvider : IPolicyDataProvider
|
||||
{
|
||||
private readonly IPolicyEvaluationClient _policyClient;
|
||||
private readonly ILogger<PolicyDataProvider> _logger;
|
||||
|
||||
public PolicyDataProvider(
|
||||
IPolicyEvaluationClient policyClient,
|
||||
ILogger<PolicyDataProvider> logger)
|
||||
{
|
||||
_policyClient = policyClient ?? throw new ArgumentNullException(nameof(policyClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<PolicyData?> GetPolicyEvaluationsAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string findingId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(environment);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetching policy evaluations for tenant {TenantId}, artifact {ArtifactDigest}, finding {FindingId}, env {Environment}",
|
||||
tenantId, TruncateDigest(artifactDigest), findingId, environment);
|
||||
|
||||
try
|
||||
{
|
||||
var evaluations = await _policyClient.EvaluatePoliciesAsync(
|
||||
tenantId,
|
||||
artifactDigest,
|
||||
findingId,
|
||||
environment,
|
||||
cancellationToken);
|
||||
|
||||
if (evaluations is null || evaluations.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No policy evaluations found for {FindingId}", findingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PolicyData
|
||||
{
|
||||
Evaluations = evaluations
|
||||
.Select(e => new PolicyEvaluationData
|
||||
{
|
||||
PolicyId = e.PolicyId,
|
||||
Decision = e.Decision,
|
||||
Reason = e.Reason,
|
||||
K4Position = e.K4Position,
|
||||
EvaluationId = e.EvaluationId
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to fetch policy evaluations for {FindingId}, returning null",
|
||||
findingId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest) =>
|
||||
digest.Length > 16 ? digest[..16] + "..." : digest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for Policy Engine.
|
||||
/// </summary>
|
||||
public interface IPolicyEvaluationClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates policies for a finding.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PolicyEvaluationResult>?> EvaluatePoliciesAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string findingId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation result from Policy Engine.
|
||||
/// </summary>
|
||||
public sealed record PolicyEvaluationResult
|
||||
{
|
||||
public required string PolicyId { get; init; }
|
||||
public required string Decision { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public string? K4Position { get; init; }
|
||||
public string? EvaluationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IPolicyEvaluationClient.
|
||||
/// </summary>
|
||||
internal sealed class NullPolicyEvaluationClient : IPolicyEvaluationClient
|
||||
{
|
||||
public Task<IReadOnlyList<PolicyEvaluationResult>?> EvaluatePoliciesAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string findingId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<PolicyEvaluationResult>?>(null);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// <copyright file="ProvenanceDataProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Assembly.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves provenance and attestation data from Attestor/EvidenceLocker.
|
||||
/// </summary>
|
||||
internal sealed class ProvenanceDataProvider : IProvenanceDataProvider
|
||||
{
|
||||
private readonly IProvenanceClient _provenanceClient;
|
||||
private readonly ILogger<ProvenanceDataProvider> _logger;
|
||||
|
||||
public ProvenanceDataProvider(
|
||||
IProvenanceClient provenanceClient,
|
||||
ILogger<ProvenanceDataProvider> logger)
|
||||
{
|
||||
_provenanceClient = provenanceClient ?? throw new ArgumentNullException(nameof(provenanceClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ProvenanceData?> GetProvenanceDataAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetching provenance data for tenant {TenantId}, artifact {ArtifactDigest}",
|
||||
tenantId, TruncateDigest(artifactDigest));
|
||||
|
||||
try
|
||||
{
|
||||
// Fetch attestations and provenance in parallel
|
||||
var sbomAttestationTask = _provenanceClient.GetSbomAttestationAsync(
|
||||
tenantId,
|
||||
artifactDigest,
|
||||
cancellationToken);
|
||||
|
||||
var buildProvenanceTask = _provenanceClient.GetBuildProvenanceAsync(
|
||||
tenantId,
|
||||
artifactDigest,
|
||||
cancellationToken);
|
||||
|
||||
var rekorEntryTask = _provenanceClient.GetRekorEntryAsync(
|
||||
tenantId,
|
||||
artifactDigest,
|
||||
cancellationToken);
|
||||
|
||||
await Task.WhenAll(sbomAttestationTask, buildProvenanceTask, rekorEntryTask);
|
||||
|
||||
var sbomAttestation = await sbomAttestationTask;
|
||||
var buildProvenance = await buildProvenanceTask;
|
||||
var rekorEntry = await rekorEntryTask;
|
||||
|
||||
// Return null if no provenance data found
|
||||
if (sbomAttestation is null && buildProvenance is null && rekorEntry is null)
|
||||
{
|
||||
_logger.LogDebug("No provenance data found for {ArtifactDigest}", TruncateDigest(artifactDigest));
|
||||
return null;
|
||||
}
|
||||
|
||||
AttestationData? sbomAttestationData = null;
|
||||
if (sbomAttestation is not null)
|
||||
{
|
||||
sbomAttestationData = new AttestationData
|
||||
{
|
||||
DsseDigest = sbomAttestation.DsseDigest,
|
||||
PredicateType = sbomAttestation.PredicateType,
|
||||
SignatureValid = sbomAttestation.SignatureValid,
|
||||
SignerKeyId = sbomAttestation.SignerKeyId
|
||||
};
|
||||
}
|
||||
|
||||
BuildProvenanceData? buildProvenanceData = null;
|
||||
if (buildProvenance is not null)
|
||||
{
|
||||
buildProvenanceData = new BuildProvenanceData
|
||||
{
|
||||
DsseDigest = buildProvenance.DsseDigest,
|
||||
Builder = buildProvenance.Builder,
|
||||
SourceRepo = buildProvenance.SourceRepo,
|
||||
SourceCommit = buildProvenance.SourceCommit,
|
||||
SlsaLevel = buildProvenance.SlsaLevel
|
||||
};
|
||||
}
|
||||
|
||||
RekorEntryData? rekorEntryData = null;
|
||||
if (rekorEntry is not null)
|
||||
{
|
||||
rekorEntryData = new RekorEntryData
|
||||
{
|
||||
Uuid = rekorEntry.Uuid,
|
||||
LogIndex = rekorEntry.LogIndex,
|
||||
IntegratedTime = rekorEntry.IntegratedTime
|
||||
};
|
||||
}
|
||||
|
||||
return new ProvenanceData
|
||||
{
|
||||
SbomAttestation = sbomAttestationData,
|
||||
BuildProvenance = buildProvenanceData,
|
||||
RekorEntry = rekorEntryData
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to fetch provenance data for {ArtifactDigest}, returning null",
|
||||
TruncateDigest(artifactDigest));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest) =>
|
||||
digest.Length > 16 ? digest[..16] + "..." : digest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for provenance data.
|
||||
/// </summary>
|
||||
public interface IProvenanceClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets SBOM attestation for an artifact.
|
||||
/// </summary>
|
||||
Task<SbomAttestationResult?> GetSbomAttestationAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets build provenance for an artifact.
|
||||
/// </summary>
|
||||
Task<BuildProvenanceResult?> GetBuildProvenanceAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets Rekor transparency log entry for an artifact.
|
||||
/// </summary>
|
||||
Task<RekorEntryResult?> GetRekorEntryAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM attestation result.
|
||||
/// </summary>
|
||||
public sealed record SbomAttestationResult
|
||||
{
|
||||
public string? DsseDigest { get; init; }
|
||||
public string? PredicateType { get; init; }
|
||||
public bool? SignatureValid { get; init; }
|
||||
public string? SignerKeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build provenance result.
|
||||
/// </summary>
|
||||
public sealed record BuildProvenanceResult
|
||||
{
|
||||
public string? DsseDigest { get; init; }
|
||||
public string? Builder { get; init; }
|
||||
public string? SourceRepo { get; init; }
|
||||
public string? SourceCommit { get; init; }
|
||||
public int? SlsaLevel { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor entry result.
|
||||
/// </summary>
|
||||
public sealed record RekorEntryResult
|
||||
{
|
||||
public string? Uuid { get; init; }
|
||||
public long? LogIndex { get; init; }
|
||||
public DateTimeOffset? IntegratedTime { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IProvenanceClient.
|
||||
/// </summary>
|
||||
internal sealed class NullProvenanceClient : IProvenanceClient
|
||||
{
|
||||
public Task<SbomAttestationResult?> GetSbomAttestationAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<SbomAttestationResult?>(null);
|
||||
|
||||
public Task<BuildProvenanceResult?> GetBuildProvenanceAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<BuildProvenanceResult?>(null);
|
||||
|
||||
public Task<RekorEntryResult?> GetRekorEntryAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<RekorEntryResult?>(null);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// <copyright file="ReachabilityDataProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Assembly.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves reachability analysis data from Scanner/ReachGraph.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityDataProvider : IReachabilityDataProvider
|
||||
{
|
||||
private readonly IReachabilityClient _reachabilityClient;
|
||||
private readonly ILogger<ReachabilityDataProvider> _logger;
|
||||
|
||||
public ReachabilityDataProvider(
|
||||
IReachabilityClient reachabilityClient,
|
||||
ILogger<ReachabilityDataProvider> logger)
|
||||
{
|
||||
_reachabilityClient = reachabilityClient ?? throw new ArgumentNullException(nameof(reachabilityClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ReachabilityData?> GetReachabilityDataAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string? packagePurl,
|
||||
string vulnerabilityId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetching reachability data for tenant {TenantId}, artifact {ArtifactDigest}, vulnerability {VulnerabilityId}",
|
||||
tenantId, TruncateDigest(artifactDigest), vulnerabilityId);
|
||||
|
||||
try
|
||||
{
|
||||
var analysis = await _reachabilityClient.GetReachabilityAnalysisAsync(
|
||||
tenantId,
|
||||
artifactDigest,
|
||||
packagePurl,
|
||||
vulnerabilityId,
|
||||
cancellationToken);
|
||||
|
||||
if (analysis is null)
|
||||
{
|
||||
_logger.LogDebug("No reachability analysis found for {VulnerabilityId}", vulnerabilityId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var pathWitnesses = analysis.PathWitnesses?
|
||||
.Take(5) // Limit to prevent context explosion
|
||||
.Select(p => new PathWitnessData
|
||||
{
|
||||
WitnessId = p.WitnessId,
|
||||
Entrypoint = p.Entrypoint,
|
||||
Sink = p.Sink,
|
||||
PathLength = p.PathLength,
|
||||
Guards = p.Guards
|
||||
})
|
||||
.ToList();
|
||||
|
||||
ReachabilityGatesData? gates = null;
|
||||
if (analysis.Gates is not null)
|
||||
{
|
||||
gates = new ReachabilityGatesData
|
||||
{
|
||||
Reachable = analysis.Gates.Reachable,
|
||||
ConfigActivated = analysis.Gates.ConfigActivated,
|
||||
RunningUser = analysis.Gates.RunningUser,
|
||||
GateClass = analysis.Gates.GateClass
|
||||
};
|
||||
}
|
||||
|
||||
return new ReachabilityData
|
||||
{
|
||||
Status = analysis.Status,
|
||||
ConfidenceScore = analysis.ConfidenceScore,
|
||||
PathCount = analysis.PathCount,
|
||||
PathWitnesses = pathWitnesses,
|
||||
Gates = gates,
|
||||
RuntimeHits = analysis.RuntimeHits,
|
||||
CallgraphDigest = analysis.CallgraphDigest
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to fetch reachability data for {VulnerabilityId}, returning null",
|
||||
vulnerabilityId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest) =>
|
||||
digest.Length > 16 ? digest[..16] + "..." : digest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for reachability analysis.
|
||||
/// </summary>
|
||||
public interface IReachabilityClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets reachability analysis for a vulnerability.
|
||||
/// </summary>
|
||||
Task<ReachabilityAnalysisResult?> GetReachabilityAnalysisAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string? packagePurl,
|
||||
string vulnerabilityId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability analysis result.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityAnalysisResult
|
||||
{
|
||||
public string? Status { get; init; }
|
||||
public double? ConfidenceScore { get; init; }
|
||||
public int PathCount { get; init; }
|
||||
public IReadOnlyList<PathWitnessResult>? PathWitnesses { get; init; }
|
||||
public ReachabilityGatesResult? Gates { get; init; }
|
||||
public int? RuntimeHits { get; init; }
|
||||
public string? CallgraphDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Path witness in reachability analysis.
|
||||
/// </summary>
|
||||
public sealed record PathWitnessResult
|
||||
{
|
||||
public required string WitnessId { get; init; }
|
||||
public string? Entrypoint { get; init; }
|
||||
public string? Sink { get; init; }
|
||||
public int? PathLength { get; init; }
|
||||
public IReadOnlyList<string>? Guards { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability gates result.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityGatesResult
|
||||
{
|
||||
public bool? Reachable { get; init; }
|
||||
public bool? ConfigActivated { get; init; }
|
||||
public bool? RunningUser { get; init; }
|
||||
public int? GateClass { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IReachabilityClient.
|
||||
/// </summary>
|
||||
internal sealed class NullReachabilityClient : IReachabilityClient
|
||||
{
|
||||
public Task<ReachabilityAnalysisResult?> GetReachabilityAnalysisAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string? packagePurl,
|
||||
string vulnerabilityId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<ReachabilityAnalysisResult?>(null);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// <copyright file="SbomDataProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Assembly.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves SBOM and finding data from SbomService/Scanner.
|
||||
/// </summary>
|
||||
internal sealed class SbomDataProvider : ISbomDataProvider
|
||||
{
|
||||
private readonly ISbomServiceClient _sbomClient;
|
||||
private readonly IScannerFindingsClient _findingsClient;
|
||||
private readonly ILogger<SbomDataProvider> _logger;
|
||||
|
||||
public SbomDataProvider(
|
||||
ISbomServiceClient sbomClient,
|
||||
IScannerFindingsClient findingsClient,
|
||||
ILogger<SbomDataProvider> logger)
|
||||
{
|
||||
_sbomClient = sbomClient ?? throw new ArgumentNullException(nameof(sbomClient));
|
||||
_findingsClient = findingsClient ?? throw new ArgumentNullException(nameof(findingsClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<SbomData?> GetSbomDataAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetching SBOM data for tenant {TenantId}, artifact {ArtifactDigest}",
|
||||
tenantId, TruncateDigest(artifactDigest));
|
||||
|
||||
try
|
||||
{
|
||||
var sbom = await _sbomClient.GetSbomByDigestAsync(
|
||||
tenantId,
|
||||
artifactDigest,
|
||||
cancellationToken);
|
||||
|
||||
if (sbom is null)
|
||||
{
|
||||
_logger.LogDebug("No SBOM found for artifact {ArtifactDigest}", TruncateDigest(artifactDigest));
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SbomData
|
||||
{
|
||||
SbomDigest = sbom.Digest,
|
||||
ComponentCount = sbom.ComponentCount,
|
||||
Labels = sbom.Labels
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to fetch SBOM data for {ArtifactDigest}, returning null",
|
||||
TruncateDigest(artifactDigest));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<FindingData?> GetFindingDataAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string findingId,
|
||||
string? packagePurl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetching finding data for tenant {TenantId}, artifact {ArtifactDigest}, finding {FindingId}",
|
||||
tenantId, TruncateDigest(artifactDigest), findingId);
|
||||
|
||||
try
|
||||
{
|
||||
var finding = await _findingsClient.GetFindingAsync(
|
||||
tenantId,
|
||||
artifactDigest,
|
||||
findingId,
|
||||
packagePurl,
|
||||
cancellationToken);
|
||||
|
||||
if (finding is null)
|
||||
{
|
||||
_logger.LogDebug("Finding {FindingId} not found in artifact {ArtifactDigest}",
|
||||
findingId, TruncateDigest(artifactDigest));
|
||||
return null;
|
||||
}
|
||||
|
||||
return new FindingData
|
||||
{
|
||||
Type = finding.Type,
|
||||
Id = finding.Id,
|
||||
Package = finding.Package,
|
||||
Version = finding.Version,
|
||||
Severity = finding.Severity,
|
||||
CvssScore = finding.CvssScore,
|
||||
EpssScore = finding.EpssScore,
|
||||
Kev = finding.Kev,
|
||||
Description = finding.Description,
|
||||
DetectedAt = finding.DetectedAt
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to fetch finding data for {FindingId}, returning null",
|
||||
findingId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest) =>
|
||||
digest.Length > 16 ? digest[..16] + "..." : digest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for SBOM Service.
|
||||
/// </summary>
|
||||
public interface ISbomServiceClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets SBOM metadata by artifact digest.
|
||||
/// </summary>
|
||||
Task<SbomMetadataResult?> GetSbomByDigestAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for Scanner findings.
|
||||
/// </summary>
|
||||
public interface IScannerFindingsClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a specific finding from a scan.
|
||||
/// </summary>
|
||||
Task<ScannerFindingResult?> GetFindingAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string findingId,
|
||||
string? packagePurl,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM metadata result from SBOM Service.
|
||||
/// </summary>
|
||||
public sealed record SbomMetadataResult
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public int ComponentCount { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finding result from Scanner.
|
||||
/// </summary>
|
||||
public sealed record ScannerFindingResult
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Id { get; init; }
|
||||
public string? Package { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public double? CvssScore { get; init; }
|
||||
public double? EpssScore { get; init; }
|
||||
public bool? Kev { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public DateTimeOffset? DetectedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of ISbomServiceClient.
|
||||
/// </summary>
|
||||
internal sealed class NullSbomServiceClient : ISbomServiceClient
|
||||
{
|
||||
public Task<SbomMetadataResult?> GetSbomByDigestAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<SbomMetadataResult?>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IScannerFindingsClient.
|
||||
/// </summary>
|
||||
internal sealed class NullScannerFindingsClient : IScannerFindingsClient
|
||||
{
|
||||
public Task<ScannerFindingResult?> GetFindingAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
string findingId,
|
||||
string? packagePurl,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<ScannerFindingResult?>(null);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// <copyright file="VexDataProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Assembly.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves VEX verdicts and observations from VexLens.
|
||||
/// </summary>
|
||||
internal sealed class VexDataProvider : IVexDataProvider
|
||||
{
|
||||
private readonly IVexLensClient _vexLensClient;
|
||||
private readonly ILogger<VexDataProvider> _logger;
|
||||
|
||||
public VexDataProvider(
|
||||
IVexLensClient vexLensClient,
|
||||
ILogger<VexDataProvider> logger)
|
||||
{
|
||||
_vexLensClient = vexLensClient ?? throw new ArgumentNullException(nameof(vexLensClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<VexData?> GetVexDataAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetching VEX data for tenant {TenantId}, vulnerability {VulnerabilityId}, package {Package}",
|
||||
tenantId, vulnerabilityId, packagePurl ?? "(all)");
|
||||
|
||||
try
|
||||
{
|
||||
var consensus = await _vexLensClient.GetConsensusAsync(
|
||||
tenantId,
|
||||
vulnerabilityId,
|
||||
packagePurl,
|
||||
cancellationToken);
|
||||
|
||||
if (consensus is null)
|
||||
{
|
||||
_logger.LogDebug("No VEX consensus found for {VulnerabilityId}", vulnerabilityId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var observations = await _vexLensClient.GetObservationsAsync(
|
||||
tenantId,
|
||||
vulnerabilityId,
|
||||
packagePurl,
|
||||
cancellationToken);
|
||||
|
||||
return new VexData
|
||||
{
|
||||
ConsensusStatus = consensus.Status,
|
||||
ConsensusJustification = consensus.Justification,
|
||||
ConfidenceScore = consensus.ConfidenceScore,
|
||||
ConsensusOutcome = consensus.Outcome,
|
||||
LinksetId = consensus.LinksetId,
|
||||
Observations = observations?
|
||||
.Select(o => new VexObservationData
|
||||
{
|
||||
ObservationId = o.ObservationId,
|
||||
ProviderId = o.ProviderId,
|
||||
Status = o.Status,
|
||||
Justification = o.Justification,
|
||||
ConfidenceScore = o.ConfidenceScore
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to fetch VEX data for {VulnerabilityId}, returning null",
|
||||
vulnerabilityId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for VexLens service.
|
||||
/// </summary>
|
||||
public interface IVexLensClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the VEX consensus for a vulnerability.
|
||||
/// </summary>
|
||||
Task<VexConsensusResult?> GetConsensusAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets individual VEX observations for a vulnerability.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<VexObservationResult>?> GetObservationsAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX consensus result from VexLens.
|
||||
/// </summary>
|
||||
public sealed record VexConsensusResult
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public double? ConfidenceScore { get; init; }
|
||||
public string? Outcome { get; init; }
|
||||
public string? LinksetId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual VEX observation result.
|
||||
/// </summary>
|
||||
public sealed record VexObservationResult
|
||||
{
|
||||
public required string ObservationId { get; init; }
|
||||
public required string ProviderId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public double? ConfidenceScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IVexLensClient for testing and when VexLens is not configured.
|
||||
/// </summary>
|
||||
internal sealed class NullVexLensClient : IVexLensClient
|
||||
{
|
||||
public Task<VexConsensusResult?> GetConsensusAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<VexConsensusResult?>(null);
|
||||
|
||||
public Task<IReadOnlyList<VexObservationResult>?> GetObservationsAsync(
|
||||
string tenantId,
|
||||
string vulnerabilityId,
|
||||
string? packagePurl,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<VexObservationResult>?>(null);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// <copyright file="AdvisoryChatServiceCollectionExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Chat.Assembly;
|
||||
using StellaOps.AdvisoryAI.Chat.Assembly.Providers;
|
||||
using StellaOps.AdvisoryAI.Chat.Inference;
|
||||
using StellaOps.AdvisoryAI.Chat.Options;
|
||||
using StellaOps.AdvisoryAI.Chat.Routing;
|
||||
using StellaOps.AdvisoryAI.Chat.Services;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// DI registration extensions for Advisory Chat.
|
||||
/// </summary>
|
||||
public static class AdvisoryChatServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds all Advisory Chat services.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAdvisoryChat(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
return services
|
||||
.AddAdvisoryChatOptions(configuration)
|
||||
.AddAdvisoryChatCore()
|
||||
.AddAdvisoryChatDataProviders()
|
||||
.AddAdvisoryChatInference(configuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Advisory Chat configuration with validation.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAdvisoryChatOptions(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<AdvisoryChatOptions>()
|
||||
.Bind(configuration.GetSection(AdvisoryChatOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<IValidateOptions<AdvisoryChatOptions>, AdvisoryChatOptionsValidator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds core Advisory Chat services.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAdvisoryChatCore(this IServiceCollection services)
|
||||
{
|
||||
// Intent routing
|
||||
services.TryAddSingleton<IAdvisoryChatIntentRouter, AdvisoryChatIntentRouter>();
|
||||
|
||||
// Evidence assembly
|
||||
services.TryAddScoped<IEvidenceBundleAssembler, EvidenceBundleAssembler>();
|
||||
|
||||
// Main orchestrator
|
||||
services.TryAddScoped<IAdvisoryChatService, AdvisoryChatService>();
|
||||
|
||||
// System prompt loader
|
||||
services.TryAddSingleton<ISystemPromptLoader, SystemPromptLoader>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds all 9 data providers with null implementations as defaults.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAdvisoryChatDataProviders(this IServiceCollection services)
|
||||
{
|
||||
// Core providers
|
||||
services.TryAddScoped<IVexDataProvider, VexDataProvider>();
|
||||
services.TryAddScoped<ISbomDataProvider, SbomDataProvider>();
|
||||
services.TryAddScoped<IReachabilityDataProvider, ReachabilityDataProvider>();
|
||||
services.TryAddScoped<IBinaryPatchDataProvider, BinaryPatchDataProvider>();
|
||||
|
||||
// Context providers
|
||||
services.TryAddScoped<IOpsMemoryDataProvider, OpsMemoryDataProvider>();
|
||||
services.TryAddScoped<IPolicyDataProvider, PolicyDataProvider>();
|
||||
services.TryAddScoped<IProvenanceDataProvider, ProvenanceDataProvider>();
|
||||
services.TryAddScoped<IFixDataProvider, FixDataProvider>();
|
||||
services.TryAddScoped<IContextDataProvider, ContextDataProvider>();
|
||||
|
||||
// Register null client implementations as defaults (can be overridden)
|
||||
services.TryAddScoped<IVexLensClient, NullVexLensClient>();
|
||||
services.TryAddScoped<ISbomServiceClient, NullSbomServiceClient>();
|
||||
services.TryAddScoped<IScannerFindingsClient, NullScannerFindingsClient>();
|
||||
services.TryAddScoped<IReachabilityClient, NullReachabilityClient>();
|
||||
services.TryAddScoped<IBinaryPatchClient, NullBinaryPatchClient>();
|
||||
services.TryAddScoped<IOpsMemoryClient, NullOpsMemoryClient>();
|
||||
services.TryAddScoped<IPolicyEvaluationClient, NullPolicyEvaluationClient>();
|
||||
services.TryAddScoped<IProvenanceClient, NullProvenanceClient>();
|
||||
services.TryAddScoped<IFixAvailabilityClient, NullFixAvailabilityClient>();
|
||||
services.TryAddScoped<IOrganizationContextClient, NullOrganizationContextClient>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds inference client based on configuration.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAdvisoryChatInference(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
var provider = configuration.GetValue<string>("AdvisoryAI:Chat:Inference:Provider") ?? "claude";
|
||||
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"claude" => services.AddClaudeInferenceClient(configuration),
|
||||
"openai" => services.AddOpenAIInferenceClient(configuration),
|
||||
"ollama" => services.AddOllamaInferenceClient(configuration),
|
||||
"local" => services.AddLocalInferenceClient(),
|
||||
_ => throw new InvalidOperationException($"Unknown inference provider: {provider}")
|
||||
};
|
||||
}
|
||||
|
||||
private static IServiceCollection AddClaudeInferenceClient(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddHttpClient<IAdvisoryChatInferenceClient, ClaudeInferenceClient>()
|
||||
.ConfigureHttpClient((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AdvisoryChatOptions>>().Value;
|
||||
var baseUrl = options.Inference.BaseUrl ?? "https://api.anthropic.com";
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(options.Inference.TimeoutSeconds);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static IServiceCollection AddOpenAIInferenceClient(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddHttpClient<IAdvisoryChatInferenceClient, OpenAIInferenceClient>()
|
||||
.ConfigureHttpClient((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AdvisoryChatOptions>>().Value;
|
||||
var baseUrl = options.Inference.BaseUrl ?? "https://api.openai.com";
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(options.Inference.TimeoutSeconds);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static IServiceCollection AddOllamaInferenceClient(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddHttpClient<IAdvisoryChatInferenceClient, OllamaInferenceClient>()
|
||||
.ConfigureHttpClient((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AdvisoryChatOptions>>().Value;
|
||||
var baseUrl = options.Inference.BaseUrl ?? "http://localhost:11434";
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(options.Inference.TimeoutSeconds);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static IServiceCollection AddLocalInferenceClient(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<IAdvisoryChatInferenceClient, LocalInferenceClient>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// <copyright file="ClaudeInferenceClient.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Net.Http.Json;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Chat.Options;
|
||||
using StellaOps.AdvisoryAI.Chat.Routing;
|
||||
|
||||
// Use namespace alias to avoid conflicts with types in parent StellaOps.AdvisoryAI.Chat namespace
|
||||
using Models = StellaOps.AdvisoryAI.Chat.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Inference;
|
||||
|
||||
/// <summary>
|
||||
/// Claude API inference client.
|
||||
/// </summary>
|
||||
internal sealed partial class ClaudeInferenceClient : IAdvisoryChatInferenceClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<AdvisoryChatOptions> _options;
|
||||
private readonly ISystemPromptLoader _promptLoader;
|
||||
private readonly ILogger<ClaudeInferenceClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public ClaudeInferenceClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<AdvisoryChatOptions> options,
|
||||
ISystemPromptLoader promptLoader,
|
||||
ILogger<ClaudeInferenceClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_promptLoader = promptLoader ?? throw new ArgumentNullException(nameof(promptLoader));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<Models.AdvisoryChatResponse> GetResponseAsync(
|
||||
Models.AdvisoryChatEvidenceBundle bundle,
|
||||
IntentRoutingResult intent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var systemPrompt = await _promptLoader.LoadSystemPromptAsync(cancellationToken);
|
||||
var userMessage = FormatUserMessage(bundle, intent);
|
||||
|
||||
var request = new ClaudeMessageRequest
|
||||
{
|
||||
Model = _options.Value.Inference.Model,
|
||||
MaxTokens = _options.Value.Inference.MaxTokens,
|
||||
Temperature = _options.Value.Inference.Temperature,
|
||||
System = systemPrompt,
|
||||
Messages =
|
||||
[
|
||||
new ClaudeMessage { Role = "user", Content = userMessage }
|
||||
]
|
||||
};
|
||||
|
||||
_logger.LogDebug("Sending inference request to Claude API for intent {Intent}", intent.Intent);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/v1/messages",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ClaudeMessageResponse>(
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
throw new AdvisoryChatInferenceException("Empty response from Claude API");
|
||||
}
|
||||
|
||||
return ParseResponse(result);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "HTTP error calling Claude API");
|
||||
throw new AdvisoryChatInferenceException("Failed to call Claude API", ex);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse Claude API response");
|
||||
throw new AdvisoryChatInferenceException("Failed to parse Claude API response", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AdvisoryChatResponseChunk> StreamResponseAsync(
|
||||
Models.AdvisoryChatEvidenceBundle bundle,
|
||||
IntentRoutingResult intent,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var systemPrompt = await _promptLoader.LoadSystemPromptAsync(cancellationToken);
|
||||
var userMessage = FormatUserMessage(bundle, intent);
|
||||
|
||||
var request = new ClaudeMessageRequest
|
||||
{
|
||||
Model = _options.Value.Inference.Model,
|
||||
MaxTokens = _options.Value.Inference.MaxTokens,
|
||||
Temperature = _options.Value.Inference.Temperature,
|
||||
System = systemPrompt,
|
||||
Messages =
|
||||
[
|
||||
new ClaudeMessage { Role = "user", Content = userMessage }
|
||||
],
|
||||
Stream = true
|
||||
};
|
||||
|
||||
_logger.LogDebug("Starting streaming inference request to Claude API");
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/v1/messages")
|
||||
{
|
||||
Content = JsonContent.Create(request, options: JsonOptions)
|
||||
};
|
||||
|
||||
using var response = await _httpClient.SendAsync(
|
||||
httpRequest,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var fullContent = new StringBuilder();
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken)) is not null)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var json = line[6..];
|
||||
if (json == "[DONE]")
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
ClaudeStreamEvent? chunk;
|
||||
try
|
||||
{
|
||||
chunk = JsonSerializer.Deserialize<ClaudeStreamEvent>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chunk?.Delta?.Text is not null)
|
||||
{
|
||||
fullContent.Append(chunk.Delta.Text);
|
||||
yield return new AdvisoryChatResponseChunk
|
||||
{
|
||||
Content = chunk.Delta.Text,
|
||||
IsComplete = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Parse final response
|
||||
var finalResponse = ParseResponseFromText(fullContent.ToString());
|
||||
yield return new AdvisoryChatResponseChunk
|
||||
{
|
||||
Content = string.Empty,
|
||||
IsComplete = true,
|
||||
FinalResponse = finalResponse
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatUserMessage(
|
||||
Models.AdvisoryChatEvidenceBundle bundle,
|
||||
IntentRoutingResult intent)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("## User Query");
|
||||
sb.AppendLine(intent.NormalizedInput);
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Detected Intent");
|
||||
sb.AppendLine($"- Intent: {intent.Intent}");
|
||||
sb.AppendLine($"- Confidence: {intent.Confidence:F2}");
|
||||
if (intent.ExplicitSlashCommand)
|
||||
{
|
||||
sb.AppendLine("- Source: Explicit slash command");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Evidence Bundle");
|
||||
sb.AppendLine("```json");
|
||||
sb.AppendLine(JsonSerializer.Serialize(bundle, JsonOptions));
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("Please analyze this evidence and provide your assessment following the response structure.");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private Models.AdvisoryChatResponse ParseResponse(ClaudeMessageResponse response)
|
||||
{
|
||||
var text = response.Content?.FirstOrDefault()?.Text;
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
throw new AdvisoryChatInferenceException("No text content in Claude API response");
|
||||
}
|
||||
|
||||
return ParseResponseFromText(text);
|
||||
}
|
||||
|
||||
private Models.AdvisoryChatResponse ParseResponseFromText(string text)
|
||||
{
|
||||
// Try to extract JSON from response
|
||||
var jsonMatch = JsonBlockPattern().Match(text);
|
||||
if (jsonMatch.Success)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<Models.AdvisoryChatResponse>(
|
||||
jsonMatch.Groups[1].Value,
|
||||
JsonOptions);
|
||||
|
||||
if (parsed is not null)
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse structured JSON response, falling back to text extraction");
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: create a basic response from the text
|
||||
return CreateFallbackResponse(text);
|
||||
}
|
||||
|
||||
private static Models.AdvisoryChatResponse CreateFallbackResponse(string text)
|
||||
{
|
||||
var responseId = $"sha256:{Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))).ToLowerInvariant()[..32]}";
|
||||
return new Models.AdvisoryChatResponse
|
||||
{
|
||||
ResponseId = responseId,
|
||||
Intent = Models.AdvisoryChatIntent.General,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Summary = text,
|
||||
Impact = null,
|
||||
ReachabilityAssessment = null,
|
||||
Mitigations = ImmutableArray<Models.MitigationOption>.Empty,
|
||||
EvidenceLinks = ImmutableArray<Models.EvidenceLink>.Empty,
|
||||
Confidence = new Models.ConfidenceAssessment
|
||||
{
|
||||
Level = Models.ConfidenceLevel.Medium,
|
||||
Score = 0.5
|
||||
},
|
||||
ProposedActions = ImmutableArray<Models.ProposedAction>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"```json\s*(.*?)\s*```", RegexOptions.Singleline)]
|
||||
private static partial Regex JsonBlockPattern();
|
||||
}
|
||||
|
||||
#region Claude API Models
|
||||
|
||||
internal sealed record ClaudeMessageRequest
|
||||
{
|
||||
public required string Model { get; init; }
|
||||
public required int MaxTokens { get; init; }
|
||||
public double? Temperature { get; init; }
|
||||
public string? System { get; init; }
|
||||
public required ClaudeMessage[] Messages { get; init; }
|
||||
public bool? Stream { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ClaudeMessage
|
||||
{
|
||||
public required string Role { get; init; }
|
||||
public required string Content { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ClaudeMessageResponse
|
||||
{
|
||||
public string? Id { get; init; }
|
||||
public string? Type { get; init; }
|
||||
public string? Role { get; init; }
|
||||
public ClaudeContentBlock[]? Content { get; init; }
|
||||
public string? Model { get; init; }
|
||||
public string? StopReason { get; init; }
|
||||
public ClaudeUsage? Usage { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ClaudeContentBlock
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public string? Text { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ClaudeUsage
|
||||
{
|
||||
public int InputTokens { get; init; }
|
||||
public int OutputTokens { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ClaudeStreamEvent
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public int? Index { get; init; }
|
||||
public ClaudeStreamDelta? Delta { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ClaudeStreamDelta
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public string? Text { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,87 @@
|
||||
// <copyright file="IAdvisoryChatInferenceClient.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.AdvisoryAI.Chat.Models;
|
||||
using StellaOps.AdvisoryAI.Chat.Routing;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Inference;
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for LLM inference.
|
||||
/// </summary>
|
||||
public interface IAdvisoryChatInferenceClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a chat response from the model.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The evidence bundle.</param>
|
||||
/// <param name="intent">The routing result with intent and parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The chat response.</returns>
|
||||
Task<AdvisoryChatResponse> GetResponseAsync(
|
||||
AdvisoryChatEvidenceBundle bundle,
|
||||
IntentRoutingResult intent,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Streams a chat response from the model.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The evidence bundle.</param>
|
||||
/// <param name="intent">The routing result with intent and parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of response chunks.</returns>
|
||||
IAsyncEnumerable<AdvisoryChatResponseChunk> StreamResponseAsync(
|
||||
AdvisoryChatEvidenceBundle bundle,
|
||||
IntentRoutingResult intent,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A chunk of a streaming chat response.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryChatResponseChunk
|
||||
{
|
||||
/// <summary>
|
||||
/// The content of this chunk.
|
||||
/// </summary>
|
||||
public required string Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the final chunk.
|
||||
/// </summary>
|
||||
public bool IsComplete { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The final parsed response (only present when IsComplete is true).
|
||||
/// </summary>
|
||||
public AdvisoryChatResponse? FinalResponse { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for loading the system prompt.
|
||||
/// </summary>
|
||||
public interface ISystemPromptLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads the system prompt.
|
||||
/// </summary>
|
||||
Task<string> LoadSystemPromptAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when inference fails.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryChatInferenceException : Exception
|
||||
{
|
||||
public AdvisoryChatInferenceException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public AdvisoryChatInferenceException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// <copyright file="LocalInferenceClient.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Chat.Routing;
|
||||
|
||||
// Use namespace alias to avoid conflicts with types in parent StellaOps.AdvisoryAI.Chat namespace
|
||||
using Models = StellaOps.AdvisoryAI.Chat.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Inference;
|
||||
|
||||
/// <summary>
|
||||
/// Local inference client for development/testing without external API calls.
|
||||
/// Returns template responses based on intent.
|
||||
/// </summary>
|
||||
internal sealed class LocalInferenceClient : IAdvisoryChatInferenceClient
|
||||
{
|
||||
private readonly ILogger<LocalInferenceClient> _logger;
|
||||
|
||||
public LocalInferenceClient(ILogger<LocalInferenceClient> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<Models.AdvisoryChatResponse> GetResponseAsync(
|
||||
Models.AdvisoryChatEvidenceBundle bundle,
|
||||
IntentRoutingResult intent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Local inference client generating response for intent {Intent}", intent.Intent);
|
||||
|
||||
var response = GenerateLocalResponse(bundle, intent);
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AdvisoryChatResponseChunk> StreamResponseAsync(
|
||||
Models.AdvisoryChatEvidenceBundle bundle,
|
||||
IntentRoutingResult intent,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Local inference client streaming response for intent {Intent}", intent.Intent);
|
||||
|
||||
var response = GenerateLocalResponse(bundle, intent);
|
||||
var summary = response.Summary ?? "No summary available.";
|
||||
|
||||
// Simulate streaming by breaking the response into chunks
|
||||
var words = summary.Split(' ');
|
||||
foreach (var word in words)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await Task.Delay(50, cancellationToken); // Simulate latency
|
||||
|
||||
yield return new AdvisoryChatResponseChunk
|
||||
{
|
||||
Content = word + " ",
|
||||
IsComplete = false
|
||||
};
|
||||
}
|
||||
|
||||
yield return new AdvisoryChatResponseChunk
|
||||
{
|
||||
Content = string.Empty,
|
||||
IsComplete = true,
|
||||
FinalResponse = response
|
||||
};
|
||||
}
|
||||
|
||||
private static Models.AdvisoryChatResponse GenerateLocalResponse(
|
||||
Models.AdvisoryChatEvidenceBundle bundle,
|
||||
IntentRoutingResult intent)
|
||||
{
|
||||
var finding = bundle.Finding;
|
||||
var verdicts = bundle.Verdicts;
|
||||
var reachability = bundle.Reachability;
|
||||
|
||||
var summary = intent.Intent switch
|
||||
{
|
||||
Models.AdvisoryChatIntent.Explain => GenerateExplainSummary(finding, verdicts),
|
||||
Models.AdvisoryChatIntent.IsItReachable => GenerateReachabilitySummary(finding, reachability),
|
||||
Models.AdvisoryChatIntent.DoWeHaveABackport => GenerateBackportSummary(finding, reachability),
|
||||
Models.AdvisoryChatIntent.ProposeFix => GenerateFixSummary(finding, bundle.Fixes),
|
||||
Models.AdvisoryChatIntent.Waive => GenerateWaiveSummary(finding, intent),
|
||||
Models.AdvisoryChatIntent.BatchTriage => "Batch triage analysis would be performed here.",
|
||||
Models.AdvisoryChatIntent.Compare => "Environment comparison would be performed here.",
|
||||
_ => $"Analysis of {finding?.Id ?? "unknown finding"} would be performed here."
|
||||
};
|
||||
|
||||
var evidenceLinks = new List<Models.EvidenceLink>();
|
||||
|
||||
if (verdicts?.Vex is not null && verdicts.Vex.Observations.Length > 0)
|
||||
{
|
||||
foreach (var obs in verdicts.Vex.Observations.Take(3))
|
||||
{
|
||||
evidenceLinks.Add(new Models.EvidenceLink
|
||||
{
|
||||
Type = Models.EvidenceLinkType.Vex,
|
||||
Link = $"vex:{obs.ProviderId}:{obs.ObservationId}",
|
||||
Description = $"VEX observation from {obs.ProviderId}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (reachability?.PathWitnesses is { Length: > 0 })
|
||||
{
|
||||
foreach (var path in reachability.PathWitnesses.Take(2))
|
||||
{
|
||||
evidenceLinks.Add(new Models.EvidenceLink
|
||||
{
|
||||
Type = Models.EvidenceLinkType.Reach,
|
||||
Link = $"reach:{path.WitnessId}",
|
||||
Description = $"Path from {path.Entrypoint} to {path.Sink}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var responseId = GenerateResponseId(bundle.BundleId, intent.Intent, DateTimeOffset.UtcNow);
|
||||
|
||||
return new Models.AdvisoryChatResponse
|
||||
{
|
||||
ResponseId = responseId,
|
||||
BundleId = bundle.BundleId,
|
||||
Intent = intent.Intent,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Summary = summary,
|
||||
Impact = GenerateImpactAssessment(finding),
|
||||
ReachabilityAssessment = reachability is not null
|
||||
? new Models.ReachabilityAssessment
|
||||
{
|
||||
Status = reachability.Status,
|
||||
CallgraphPaths = reachability.CallgraphPaths,
|
||||
PathDescription = $"Reachability status: {reachability.Status}"
|
||||
}
|
||||
: null,
|
||||
Mitigations = GenerateMitigations(bundle),
|
||||
EvidenceLinks = evidenceLinks.ToImmutableArray(),
|
||||
Confidence = new Models.ConfidenceAssessment
|
||||
{
|
||||
Level = Models.ConfidenceLevel.Medium,
|
||||
Score = 0.7,
|
||||
Factors =
|
||||
[
|
||||
new Models.ConfidenceFactor
|
||||
{
|
||||
Factor = "evidence_completeness",
|
||||
Impact = Models.ConfidenceImpact.Positive,
|
||||
Weight = 0.8
|
||||
}
|
||||
]
|
||||
},
|
||||
ProposedActions = ImmutableArray<Models.ProposedAction>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateExplainSummary(Models.EvidenceFinding? finding, Models.EvidenceVerdicts? verdicts)
|
||||
{
|
||||
if (finding is null)
|
||||
{
|
||||
return "No finding data available for explanation.";
|
||||
}
|
||||
|
||||
var vexStatus = verdicts?.Vex?.Status.ToString() ?? "unknown";
|
||||
return $"{finding.Id} is a {finding.Severity.ToString().ToLowerInvariant()} " +
|
||||
$"vulnerability affecting {finding.Package ?? "unknown package"} version {finding.Version ?? "unknown"}. " +
|
||||
$"VEX consensus status: {vexStatus}. " +
|
||||
$"CVSS score: {finding.CvssScore?.ToString("F1") ?? "N/A"}, EPSS score: {finding.EpssScore?.ToString("P2") ?? "N/A"}.";
|
||||
}
|
||||
|
||||
private static string GenerateReachabilitySummary(Models.EvidenceFinding? finding, Models.EvidenceReachability? reachability)
|
||||
{
|
||||
if (reachability is null)
|
||||
{
|
||||
return $"No reachability analysis available for {finding?.Id ?? "this finding"}.";
|
||||
}
|
||||
|
||||
var pathCount = reachability.CallgraphPaths ?? 0;
|
||||
return reachability.Status switch
|
||||
{
|
||||
Models.ReachabilityStatus.Reachable => $"{finding?.Id} is REACHABLE. Found {pathCount} call paths to vulnerable code.",
|
||||
Models.ReachabilityStatus.Unreachable => $"{finding?.Id} is NOT REACHABLE. The vulnerable code is not in any execution path.",
|
||||
Models.ReachabilityStatus.Conditional => $"{finding?.Id} has CONDITIONAL reachability. It may be reachable depending on configuration.",
|
||||
_ => $"Reachability status for {finding?.Id} is unknown."
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateBackportSummary(Models.EvidenceFinding? finding, Models.EvidenceReachability? reachability)
|
||||
{
|
||||
var binaryPatch = reachability?.BinaryPatch;
|
||||
if (binaryPatch is null)
|
||||
{
|
||||
return $"No binary patch detection available for {finding?.Id ?? "this finding"}.";
|
||||
}
|
||||
|
||||
if (binaryPatch.Detected)
|
||||
{
|
||||
return $"A binary backport for {finding?.Id} HAS been detected. " +
|
||||
$"Match method: {binaryPatch.MatchMethod?.ToString() ?? "unknown"}, " +
|
||||
$"confidence: {binaryPatch.Confidence?.ToString("P0") ?? "N/A"}. " +
|
||||
$"Distro advisory: {binaryPatch.DistroAdvisory ?? "N/A"}.";
|
||||
}
|
||||
|
||||
return $"No binary backport detected for {finding?.Id}. The vulnerability may still be present.";
|
||||
}
|
||||
|
||||
private static string GenerateFixSummary(Models.EvidenceFinding? finding, Models.EvidenceFixes? fixes)
|
||||
{
|
||||
if (fixes is null)
|
||||
{
|
||||
return $"No fix information available for {finding?.Id ?? "this finding"}.";
|
||||
}
|
||||
|
||||
var options = new List<string>();
|
||||
|
||||
if (fixes.Upgrade is { Length: > 0 })
|
||||
{
|
||||
var latest = fixes.Upgrade[0];
|
||||
options.Add($"Upgrade to version {latest.Version}");
|
||||
}
|
||||
|
||||
if (fixes.DistroBackport?.Available == true)
|
||||
{
|
||||
options.Add($"Apply distro backport: {fixes.DistroBackport.Advisory}");
|
||||
}
|
||||
|
||||
if (fixes.Config is { Length: > 0 })
|
||||
{
|
||||
options.Add($"Apply config fix: {fixes.Config[0].Option}");
|
||||
}
|
||||
|
||||
return options.Count > 0
|
||||
? $"Available fixes for {finding?.Id}: " + string.Join("; ", options)
|
||||
: $"No known fixes available for {finding?.Id}.";
|
||||
}
|
||||
|
||||
private static string GenerateWaiveSummary(Models.EvidenceFinding? finding, IntentRoutingResult intent)
|
||||
{
|
||||
return $"Waiver request for {finding?.Id ?? intent.Parameters.FindingId ?? "unknown"} " +
|
||||
$"for {intent.Parameters.Duration ?? "unspecified duration"} " +
|
||||
$"because: {intent.Parameters.Reason ?? "no reason provided"}. " +
|
||||
"This would require policy approval.";
|
||||
}
|
||||
|
||||
private static string GenerateResponseId(string? bundleId, Models.AdvisoryChatIntent intent, DateTimeOffset generatedAt)
|
||||
{
|
||||
var input = $"{bundleId}:{intent}:{generatedAt:O}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static Models.ImpactAssessment? GenerateImpactAssessment(Models.EvidenceFinding? finding)
|
||||
{
|
||||
if (finding is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Models.ImpactAssessment
|
||||
{
|
||||
AffectedComponent = finding.Package,
|
||||
AffectedVersion = finding.Version,
|
||||
Description = $"Severity: {finding.Severity}. " +
|
||||
(finding.Kev == true ? "This vulnerability is in CISA KEV (Known Exploited Vulnerabilities). " : "") +
|
||||
$"Affects package: {finding.Package ?? "unknown"}."
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<Models.MitigationOption> GenerateMitigations(Models.AdvisoryChatEvidenceBundle bundle)
|
||||
{
|
||||
var mitigations = new List<Models.MitigationOption>();
|
||||
var rank = 1;
|
||||
|
||||
if (bundle.Fixes?.Upgrade is { Length: > 0 })
|
||||
{
|
||||
mitigations.Add(new Models.MitigationOption
|
||||
{
|
||||
Rank = rank++,
|
||||
Type = Models.MitigationType.UpgradePackage,
|
||||
Label = $"Upgrade to {bundle.Fixes.Upgrade[0].Version}",
|
||||
Description = $"Upgrade the affected package to version {bundle.Fixes.Upgrade[0].Version}",
|
||||
Risk = Models.MitigationRisk.Medium,
|
||||
BreakingChanges = bundle.Fixes.Upgrade[0].BreakingChanges,
|
||||
EstimatedEffort = "Medium"
|
||||
});
|
||||
}
|
||||
|
||||
if (bundle.Fixes?.DistroBackport?.Available == true)
|
||||
{
|
||||
mitigations.Add(new Models.MitigationOption
|
||||
{
|
||||
Rank = rank++,
|
||||
Type = Models.MitigationType.AcceptBackport,
|
||||
Label = "Accept distro backport",
|
||||
Description = $"Apply distro backport: {bundle.Fixes.DistroBackport.Advisory}",
|
||||
Risk = Models.MitigationRisk.Low,
|
||||
EstimatedEffort = "Low"
|
||||
});
|
||||
}
|
||||
|
||||
if (bundle.Fixes?.Containment is { Length: > 0 })
|
||||
{
|
||||
mitigations.Add(new Models.MitigationOption
|
||||
{
|
||||
Rank = rank++,
|
||||
Type = Models.MitigationType.RuntimeContainment,
|
||||
Label = "Apply containment",
|
||||
Description = bundle.Fixes.Containment[0].Description ?? "Apply containment measure",
|
||||
Risk = Models.MitigationRisk.Low,
|
||||
EstimatedEffort = "Low"
|
||||
});
|
||||
}
|
||||
|
||||
return mitigations.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// <copyright file="OllamaInferenceClient.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Net.Http.Json;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Chat.Options;
|
||||
using StellaOps.AdvisoryAI.Chat.Routing;
|
||||
|
||||
// Use namespace alias to avoid conflicts with types in parent StellaOps.AdvisoryAI.Chat namespace
|
||||
using Models = StellaOps.AdvisoryAI.Chat.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Inference;
|
||||
|
||||
/// <summary>
|
||||
/// Ollama API inference client for local models.
|
||||
/// </summary>
|
||||
internal sealed partial class OllamaInferenceClient : IAdvisoryChatInferenceClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<AdvisoryChatOptions> _options;
|
||||
private readonly ISystemPromptLoader _promptLoader;
|
||||
private readonly ILogger<OllamaInferenceClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public OllamaInferenceClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<AdvisoryChatOptions> options,
|
||||
ISystemPromptLoader promptLoader,
|
||||
ILogger<OllamaInferenceClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_promptLoader = promptLoader ?? throw new ArgumentNullException(nameof(promptLoader));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<Models.AdvisoryChatResponse> GetResponseAsync(
|
||||
Models.AdvisoryChatEvidenceBundle bundle,
|
||||
IntentRoutingResult intent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var systemPrompt = await _promptLoader.LoadSystemPromptAsync(cancellationToken);
|
||||
var userMessage = FormatUserMessage(bundle, intent);
|
||||
|
||||
var request = new OllamaChatRequest
|
||||
{
|
||||
Model = _options.Value.Inference.Model,
|
||||
Messages =
|
||||
[
|
||||
new OllamaMessage { Role = "system", Content = systemPrompt },
|
||||
new OllamaMessage { Role = "user", Content = userMessage }
|
||||
],
|
||||
Stream = false,
|
||||
Options = new OllamaOptions
|
||||
{
|
||||
Temperature = _options.Value.Inference.Temperature,
|
||||
NumPredict = _options.Value.Inference.MaxTokens
|
||||
}
|
||||
};
|
||||
|
||||
_logger.LogDebug("Sending inference request to Ollama API for intent {Intent}", intent.Intent);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/api/chat",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<OllamaChatResponse>(
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
throw new AdvisoryChatInferenceException("Empty response from Ollama API");
|
||||
}
|
||||
|
||||
return ParseResponseFromText(result.Message?.Content ?? string.Empty);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "HTTP error calling Ollama API");
|
||||
throw new AdvisoryChatInferenceException("Failed to call Ollama API", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AdvisoryChatResponseChunk> StreamResponseAsync(
|
||||
Models.AdvisoryChatEvidenceBundle bundle,
|
||||
IntentRoutingResult intent,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var systemPrompt = await _promptLoader.LoadSystemPromptAsync(cancellationToken);
|
||||
var userMessage = FormatUserMessage(bundle, intent);
|
||||
|
||||
var request = new OllamaChatRequest
|
||||
{
|
||||
Model = _options.Value.Inference.Model,
|
||||
Messages =
|
||||
[
|
||||
new OllamaMessage { Role = "system", Content = systemPrompt },
|
||||
new OllamaMessage { Role = "user", Content = userMessage }
|
||||
],
|
||||
Stream = true,
|
||||
Options = new OllamaOptions
|
||||
{
|
||||
Temperature = _options.Value.Inference.Temperature,
|
||||
NumPredict = _options.Value.Inference.MaxTokens
|
||||
}
|
||||
};
|
||||
|
||||
_logger.LogDebug("Starting streaming inference request to Ollama API");
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/chat")
|
||||
{
|
||||
Content = JsonContent.Create(request, options: JsonOptions)
|
||||
};
|
||||
|
||||
using var response = await _httpClient.SendAsync(
|
||||
httpRequest,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var fullContent = new StringBuilder();
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken)) is not null)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
OllamaStreamResponse? chunk;
|
||||
try
|
||||
{
|
||||
chunk = JsonSerializer.Deserialize<OllamaStreamResponse>(line, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chunk?.Message?.Content is not null)
|
||||
{
|
||||
fullContent.Append(chunk.Message.Content);
|
||||
yield return new AdvisoryChatResponseChunk
|
||||
{
|
||||
Content = chunk.Message.Content,
|
||||
IsComplete = false
|
||||
};
|
||||
}
|
||||
|
||||
if (chunk?.Done == true)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var finalResponse = ParseResponseFromText(fullContent.ToString());
|
||||
yield return new AdvisoryChatResponseChunk
|
||||
{
|
||||
Content = string.Empty,
|
||||
IsComplete = true,
|
||||
FinalResponse = finalResponse
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatUserMessage(
|
||||
Models.AdvisoryChatEvidenceBundle bundle,
|
||||
IntentRoutingResult intent)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("## User Query");
|
||||
sb.AppendLine(intent.NormalizedInput);
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Intent: ").Append(intent.Intent);
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Evidence Bundle");
|
||||
sb.AppendLine("```json");
|
||||
sb.AppendLine(JsonSerializer.Serialize(bundle, JsonOptions));
|
||||
sb.AppendLine("```");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private Models.AdvisoryChatResponse ParseResponseFromText(string text)
|
||||
{
|
||||
var jsonMatch = JsonBlockPattern().Match(text);
|
||||
if (jsonMatch.Success)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<Models.AdvisoryChatResponse>(
|
||||
jsonMatch.Groups[1].Value,
|
||||
JsonOptions);
|
||||
|
||||
if (parsed is not null)
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse structured JSON response from Ollama");
|
||||
}
|
||||
}
|
||||
|
||||
var responseId = $"sha256:{Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))).ToLowerInvariant()[..32]}";
|
||||
return new Models.AdvisoryChatResponse
|
||||
{
|
||||
ResponseId = responseId,
|
||||
Intent = Models.AdvisoryChatIntent.General,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Summary = text,
|
||||
Impact = null,
|
||||
ReachabilityAssessment = null,
|
||||
Mitigations = ImmutableArray<Models.MitigationOption>.Empty,
|
||||
EvidenceLinks = ImmutableArray<Models.EvidenceLink>.Empty,
|
||||
Confidence = new Models.ConfidenceAssessment
|
||||
{
|
||||
Level = Models.ConfidenceLevel.Medium,
|
||||
Score = 0.5
|
||||
},
|
||||
ProposedActions = ImmutableArray<Models.ProposedAction>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"```json\s*(.*?)\s*```", RegexOptions.Singleline)]
|
||||
private static partial Regex JsonBlockPattern();
|
||||
}
|
||||
|
||||
#region Ollama API Models
|
||||
|
||||
internal sealed record OllamaChatRequest
|
||||
{
|
||||
public required string Model { get; init; }
|
||||
public required OllamaMessage[] Messages { get; init; }
|
||||
public bool? Stream { get; init; }
|
||||
public OllamaOptions? Options { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record OllamaMessage
|
||||
{
|
||||
public required string Role { get; init; }
|
||||
public required string Content { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record OllamaOptions
|
||||
{
|
||||
public double? Temperature { get; init; }
|
||||
public int? NumPredict { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record OllamaChatResponse
|
||||
{
|
||||
public string? Model { get; init; }
|
||||
public OllamaMessage? Message { get; init; }
|
||||
public bool Done { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record OllamaStreamResponse
|
||||
{
|
||||
public string? Model { get; init; }
|
||||
public OllamaMessage? Message { get; init; }
|
||||
public bool Done { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,328 @@
|
||||
// <copyright file="OpenAIInferenceClient.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Net.Http.Json;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Chat.Options;
|
||||
using StellaOps.AdvisoryAI.Chat.Routing;
|
||||
|
||||
// Use namespace alias to avoid conflicts with types in parent StellaOps.AdvisoryAI.Chat namespace
|
||||
using Models = StellaOps.AdvisoryAI.Chat.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Inference;
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI API inference client.
|
||||
/// </summary>
|
||||
internal sealed partial class OpenAIInferenceClient : IAdvisoryChatInferenceClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<AdvisoryChatOptions> _options;
|
||||
private readonly ISystemPromptLoader _promptLoader;
|
||||
private readonly ILogger<OpenAIInferenceClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public OpenAIInferenceClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<AdvisoryChatOptions> options,
|
||||
ISystemPromptLoader promptLoader,
|
||||
ILogger<OpenAIInferenceClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_promptLoader = promptLoader ?? throw new ArgumentNullException(nameof(promptLoader));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<Models.AdvisoryChatResponse> GetResponseAsync(
|
||||
Models.AdvisoryChatEvidenceBundle bundle,
|
||||
IntentRoutingResult intent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var systemPrompt = await _promptLoader.LoadSystemPromptAsync(cancellationToken);
|
||||
var userMessage = FormatUserMessage(bundle, intent);
|
||||
|
||||
var request = new OpenAIChatRequest
|
||||
{
|
||||
Model = _options.Value.Inference.Model,
|
||||
MaxTokens = _options.Value.Inference.MaxTokens,
|
||||
Temperature = _options.Value.Inference.Temperature,
|
||||
Messages =
|
||||
[
|
||||
new OpenAIChatMessage { Role = "system", Content = systemPrompt },
|
||||
new OpenAIChatMessage { Role = "user", Content = userMessage }
|
||||
]
|
||||
};
|
||||
|
||||
_logger.LogDebug("Sending inference request to OpenAI API for intent {Intent}", intent.Intent);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/v1/chat/completions",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<OpenAIChatResponse>(
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
throw new AdvisoryChatInferenceException("Empty response from OpenAI API");
|
||||
}
|
||||
|
||||
return ParseResponse(result);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "HTTP error calling OpenAI API");
|
||||
throw new AdvisoryChatInferenceException("Failed to call OpenAI API", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AdvisoryChatResponseChunk> StreamResponseAsync(
|
||||
Models.AdvisoryChatEvidenceBundle bundle,
|
||||
IntentRoutingResult intent,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var systemPrompt = await _promptLoader.LoadSystemPromptAsync(cancellationToken);
|
||||
var userMessage = FormatUserMessage(bundle, intent);
|
||||
|
||||
var request = new OpenAIChatRequest
|
||||
{
|
||||
Model = _options.Value.Inference.Model,
|
||||
MaxTokens = _options.Value.Inference.MaxTokens,
|
||||
Temperature = _options.Value.Inference.Temperature,
|
||||
Messages =
|
||||
[
|
||||
new OpenAIChatMessage { Role = "system", Content = systemPrompt },
|
||||
new OpenAIChatMessage { Role = "user", Content = userMessage }
|
||||
],
|
||||
Stream = true
|
||||
};
|
||||
|
||||
_logger.LogDebug("Starting streaming inference request to OpenAI API");
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions")
|
||||
{
|
||||
Content = JsonContent.Create(request, options: JsonOptions)
|
||||
};
|
||||
|
||||
using var response = await _httpClient.SendAsync(
|
||||
httpRequest,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var fullContent = new StringBuilder();
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken)) is not null)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var json = line[6..];
|
||||
if (json == "[DONE]")
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
OpenAIStreamChunk? chunk;
|
||||
try
|
||||
{
|
||||
chunk = JsonSerializer.Deserialize<OpenAIStreamChunk>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var content = chunk?.Choices?.FirstOrDefault()?.Delta?.Content;
|
||||
if (content is not null)
|
||||
{
|
||||
fullContent.Append(content);
|
||||
yield return new AdvisoryChatResponseChunk
|
||||
{
|
||||
Content = content,
|
||||
IsComplete = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var finalResponse = ParseResponseFromText(fullContent.ToString());
|
||||
yield return new AdvisoryChatResponseChunk
|
||||
{
|
||||
Content = string.Empty,
|
||||
IsComplete = true,
|
||||
FinalResponse = finalResponse
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatUserMessage(
|
||||
Models.AdvisoryChatEvidenceBundle bundle,
|
||||
IntentRoutingResult intent)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("## User Query");
|
||||
sb.AppendLine(intent.NormalizedInput);
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Detected Intent");
|
||||
sb.AppendLine($"- Intent: {intent.Intent}");
|
||||
sb.AppendLine($"- Confidence: {intent.Confidence:F2}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Evidence Bundle");
|
||||
sb.AppendLine("```json");
|
||||
sb.AppendLine(JsonSerializer.Serialize(bundle, JsonOptions));
|
||||
sb.AppendLine("```");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private Models.AdvisoryChatResponse ParseResponse(OpenAIChatResponse response)
|
||||
{
|
||||
var text = response.Choices?.FirstOrDefault()?.Message?.Content;
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
throw new AdvisoryChatInferenceException("No content in OpenAI API response");
|
||||
}
|
||||
|
||||
return ParseResponseFromText(text);
|
||||
}
|
||||
|
||||
private Models.AdvisoryChatResponse ParseResponseFromText(string text)
|
||||
{
|
||||
var jsonMatch = JsonBlockPattern().Match(text);
|
||||
if (jsonMatch.Success)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<Models.AdvisoryChatResponse>(
|
||||
jsonMatch.Groups[1].Value,
|
||||
JsonOptions);
|
||||
|
||||
if (parsed is not null)
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse structured JSON response");
|
||||
}
|
||||
}
|
||||
|
||||
var responseId = $"sha256:{Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))).ToLowerInvariant()[..32]}";
|
||||
return new Models.AdvisoryChatResponse
|
||||
{
|
||||
ResponseId = responseId,
|
||||
Intent = Models.AdvisoryChatIntent.General,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Summary = text,
|
||||
Impact = null,
|
||||
ReachabilityAssessment = null,
|
||||
Mitigations = ImmutableArray<Models.MitigationOption>.Empty,
|
||||
EvidenceLinks = ImmutableArray<Models.EvidenceLink>.Empty,
|
||||
Confidence = new Models.ConfidenceAssessment
|
||||
{
|
||||
Level = Models.ConfidenceLevel.Medium,
|
||||
Score = 0.5
|
||||
},
|
||||
ProposedActions = ImmutableArray<Models.ProposedAction>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"```json\s*(.*?)\s*```", RegexOptions.Singleline)]
|
||||
private static partial Regex JsonBlockPattern();
|
||||
}
|
||||
|
||||
#region OpenAI API Models
|
||||
|
||||
internal sealed record OpenAIChatRequest
|
||||
{
|
||||
public required string Model { get; init; }
|
||||
public int? MaxTokens { get; init; }
|
||||
public double? Temperature { get; init; }
|
||||
public required OpenAIChatMessage[] Messages { get; init; }
|
||||
public bool? Stream { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record OpenAIChatMessage
|
||||
{
|
||||
public required string Role { get; init; }
|
||||
public required string Content { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record OpenAIChatResponse
|
||||
{
|
||||
public string? Id { get; init; }
|
||||
public string? Object { get; init; }
|
||||
public long? Created { get; init; }
|
||||
public string? Model { get; init; }
|
||||
public OpenAIChatChoice[]? Choices { get; init; }
|
||||
public OpenAIUsage? Usage { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record OpenAIChatChoice
|
||||
{
|
||||
public int Index { get; init; }
|
||||
public OpenAIChatMessage? Message { get; init; }
|
||||
public string? FinishReason { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record OpenAIUsage
|
||||
{
|
||||
public int PromptTokens { get; init; }
|
||||
public int CompletionTokens { get; init; }
|
||||
public int TotalTokens { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record OpenAIStreamChunk
|
||||
{
|
||||
public string? Id { get; init; }
|
||||
public OpenAIStreamChoice[]? Choices { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record OpenAIStreamChoice
|
||||
{
|
||||
public int Index { get; init; }
|
||||
public OpenAIStreamDelta? Delta { get; init; }
|
||||
public string? FinishReason { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record OpenAIStreamDelta
|
||||
{
|
||||
public string? Role { get; init; }
|
||||
public string? Content { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,104 @@
|
||||
// <copyright file="SystemPromptLoader.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Inference;
|
||||
|
||||
/// <summary>
|
||||
/// Loads and caches the system prompt from embedded resources.
|
||||
/// </summary>
|
||||
internal sealed class SystemPromptLoader : ISystemPromptLoader
|
||||
{
|
||||
private readonly ILogger<SystemPromptLoader> _logger;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private string? _cachedPrompt;
|
||||
|
||||
public SystemPromptLoader(ILogger<SystemPromptLoader> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<string> LoadSystemPromptAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_cachedPrompt is not null)
|
||||
{
|
||||
return _cachedPrompt;
|
||||
}
|
||||
|
||||
await _lock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (_cachedPrompt is not null)
|
||||
{
|
||||
return _cachedPrompt;
|
||||
}
|
||||
|
||||
// Load from embedded resource
|
||||
var assembly = typeof(SystemPromptLoader).Assembly;
|
||||
var resourceName = "StellaOps.AdvisoryAI.Chat.AdvisorSystemPrompt.md";
|
||||
|
||||
await using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null)
|
||||
{
|
||||
// Fallback to reading from file system during development
|
||||
var filePath = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Chat",
|
||||
"AdvisorSystemPrompt.md");
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
_cachedPrompt = await File.ReadAllTextAsync(filePath, cancellationToken);
|
||||
_logger.LogDebug("Loaded system prompt from file ({Length} chars)", _cachedPrompt.Length);
|
||||
return _cachedPrompt;
|
||||
}
|
||||
|
||||
// Use default prompt if resource not found
|
||||
_cachedPrompt = GetDefaultSystemPrompt();
|
||||
_logger.LogWarning(
|
||||
"System prompt resource not found, using default prompt ({Length} chars)",
|
||||
_cachedPrompt.Length);
|
||||
return _cachedPrompt;
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
_cachedPrompt = await reader.ReadToEndAsync(cancellationToken);
|
||||
_logger.LogDebug("Loaded system prompt from embedded resource ({Length} chars)", _cachedPrompt.Length);
|
||||
|
||||
return _cachedPrompt;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDefaultSystemPrompt() => """
|
||||
You are an expert vulnerability advisor for the StellaOps security platform.
|
||||
|
||||
Your role is to analyze vulnerability findings and provide actionable, evidence-grounded recommendations.
|
||||
|
||||
Key principles:
|
||||
1. NEVER speculate or hallucinate - only cite evidence from the provided bundle
|
||||
2. Use evidence links in format [type:id] to reference sources
|
||||
3. Provide clear, actionable mitigations
|
||||
4. Consider reachability, binary patches, and VEX verdicts
|
||||
5. Be concise but thorough
|
||||
|
||||
Evidence link formats:
|
||||
- [sbom:{digest}:{purl}] - SBOM component reference
|
||||
- [vex:{providerId}:{observationId}] - VEX observation
|
||||
- [reach:{witnessId}] - Reachability path witness
|
||||
- [binpatch:{proofId}] - Binary patch proof
|
||||
- [policy:{evaluationId}] - Policy evaluation
|
||||
|
||||
Always structure your response with:
|
||||
1. Summary of the finding
|
||||
2. Impact assessment
|
||||
3. Reachability analysis (if available)
|
||||
4. Recommended mitigations with effort estimates
|
||||
5. Evidence links supporting your analysis
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
// <copyright file="AdvisoryChatModels.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence bundle input for Advisory AI Chat.
|
||||
/// All data sourced from Stella objects - no external sources.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryChatEvidenceBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Deterministic bundle ID: sha256(artifact.digest + finding.id + assembledAt).
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC ISO-8601 timestamp when bundle was assembled.
|
||||
/// </summary>
|
||||
public required DateTimeOffset AssembledAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The artifact (container image) being analyzed.
|
||||
/// </summary>
|
||||
public required EvidenceArtifact Artifact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The specific finding being analyzed.
|
||||
/// </summary>
|
||||
public required EvidenceFinding Finding { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX and policy verdicts.
|
||||
/// </summary>
|
||||
public EvidenceVerdicts? Verdicts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability analysis results.
|
||||
/// </summary>
|
||||
public EvidenceReachability? Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact provenance and attestations.
|
||||
/// </summary>
|
||||
public EvidenceProvenance? Provenance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Available fix options.
|
||||
/// </summary>
|
||||
public EvidenceFixes? Fixes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Organizational and operational context.
|
||||
/// </summary>
|
||||
public EvidenceContext? Context { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Historical decisions from OpsMemory.
|
||||
/// </summary>
|
||||
public EvidenceOpsMemory? OpsMemory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Engine version for reproducibility verification.
|
||||
/// </summary>
|
||||
public EvidenceEngineVersion? EngineVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The artifact (container image) being analyzed.
|
||||
/// </summary>
|
||||
public sealed record EvidenceArtifact
|
||||
{
|
||||
public string? Image { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required string Environment { get; init; }
|
||||
public string? SbomDigest { get; init; }
|
||||
public ImmutableDictionary<string, string>? Labels { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The specific finding being analyzed.
|
||||
/// </summary>
|
||||
public sealed record EvidenceFinding
|
||||
{
|
||||
public required EvidenceFindingType Type { get; init; }
|
||||
public required string Id { get; init; }
|
||||
public string? Package { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public EvidenceSeverity Severity { get; init; } = EvidenceSeverity.Unknown;
|
||||
public double? CvssScore { get; init; }
|
||||
public double? EpssScore { get; init; }
|
||||
public bool? Kev { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public DateTimeOffset? DetectedAt { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum EvidenceFindingType
|
||||
{
|
||||
Cve,
|
||||
Ghsa,
|
||||
PolicyViolation,
|
||||
SecretExposure,
|
||||
Misconfiguration
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum EvidenceSeverity
|
||||
{
|
||||
Unknown,
|
||||
None,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX and policy verdicts.
|
||||
/// </summary>
|
||||
public sealed record EvidenceVerdicts
|
||||
{
|
||||
public VexVerdict? Vex { get; init; }
|
||||
public ImmutableArray<PolicyVerdict> Policy { get; init; } = ImmutableArray<PolicyVerdict>.Empty;
|
||||
}
|
||||
|
||||
public sealed record VexVerdict
|
||||
{
|
||||
public required VexStatus Status { get; init; }
|
||||
public VexJustification? Justification { get; init; }
|
||||
public double? ConfidenceScore { get; init; }
|
||||
public VexConsensusOutcome? ConsensusOutcome { get; init; }
|
||||
public ImmutableArray<VexObservation> Observations { get; init; } = ImmutableArray<VexObservation>.Empty;
|
||||
public string? LinksetId { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum VexStatus
|
||||
{
|
||||
Affected,
|
||||
NotAffected,
|
||||
Fixed,
|
||||
UnderInvestigation,
|
||||
Unknown
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum VexJustification
|
||||
{
|
||||
ComponentNotPresent,
|
||||
VulnerableCodeNotPresent,
|
||||
VulnerableCodeNotInExecutePath,
|
||||
VulnerableCodeCannotBeControlledByAdversary,
|
||||
InlineMitigationsAlreadyExist
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum VexConsensusOutcome
|
||||
{
|
||||
Unanimous,
|
||||
Majority,
|
||||
Plurality,
|
||||
ConflictResolved
|
||||
}
|
||||
|
||||
public sealed record VexObservation
|
||||
{
|
||||
public required string ObservationId { get; init; }
|
||||
public required string ProviderId { get; init; }
|
||||
public required VexStatus Status { get; init; }
|
||||
public VexJustification? Justification { get; init; }
|
||||
public double? ConfidenceScore { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PolicyVerdict
|
||||
{
|
||||
public required string PolicyId { get; init; }
|
||||
public required PolicyDecision Decision { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public string? K4Position { get; init; }
|
||||
public string? EvaluationId { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum PolicyDecision
|
||||
{
|
||||
Allow,
|
||||
Warn,
|
||||
Block
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability analysis results.
|
||||
/// </summary>
|
||||
public sealed record EvidenceReachability
|
||||
{
|
||||
public ReachabilityStatus Status { get; init; } = ReachabilityStatus.Unknown;
|
||||
public double? ConfidenceScore { get; init; }
|
||||
public int? CallgraphPaths { get; init; }
|
||||
public ImmutableArray<PathWitness> PathWitnesses { get; init; } = ImmutableArray<PathWitness>.Empty;
|
||||
public ReachabilityGates? Gates { get; init; }
|
||||
public int? RuntimeHits { get; init; }
|
||||
public string? CallgraphDigest { get; init; }
|
||||
public BinaryPatchEvidence? BinaryPatch { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ReachabilityStatus
|
||||
{
|
||||
Reachable,
|
||||
Unreachable,
|
||||
Conditional,
|
||||
Unknown
|
||||
}
|
||||
|
||||
public sealed record PathWitness
|
||||
{
|
||||
public required string WitnessId { get; init; }
|
||||
public string? Entrypoint { get; init; }
|
||||
public string? Sink { get; init; }
|
||||
public int? PathLength { get; init; }
|
||||
public ImmutableArray<string> Guards { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
public sealed record ReachabilityGates
|
||||
{
|
||||
public bool? Reachable { get; init; }
|
||||
public bool? ConfigActivated { get; init; }
|
||||
public bool? RunningUser { get; init; }
|
||||
public int? GateClass { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BinaryPatchEvidence
|
||||
{
|
||||
public bool Detected { get; init; }
|
||||
public string? ProofId { get; init; }
|
||||
public BinaryMatchMethod? MatchMethod { get; init; }
|
||||
public double? Similarity { get; init; }
|
||||
public double? Confidence { get; init; }
|
||||
public ImmutableArray<string> PatchedSymbols { get; init; } = ImmutableArray<string>.Empty;
|
||||
public string? DistroAdvisory { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum BinaryMatchMethod
|
||||
{
|
||||
Tlsh,
|
||||
CfgHash,
|
||||
InstructionHash,
|
||||
SymbolHash,
|
||||
SectionHash
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact provenance and attestations.
|
||||
/// </summary>
|
||||
public sealed record EvidenceProvenance
|
||||
{
|
||||
public AttestationReference? SbomAttestation { get; init; }
|
||||
public BuildProvenance? BuildProvenance { get; init; }
|
||||
public RekorEntry? RekorEntry { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AttestationReference
|
||||
{
|
||||
public string? DsseDigest { get; init; }
|
||||
public string? PredicateType { get; init; }
|
||||
public bool? SignatureValid { get; init; }
|
||||
public string? SignerKeyId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BuildProvenance
|
||||
{
|
||||
public string? DsseDigest { get; init; }
|
||||
public string? Builder { get; init; }
|
||||
public string? SourceRepo { get; init; }
|
||||
public string? SourceCommit { get; init; }
|
||||
public int? SlsaLevel { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RekorEntry
|
||||
{
|
||||
public string? Uuid { get; init; }
|
||||
public long? LogIndex { get; init; }
|
||||
public DateTimeOffset? IntegratedTime { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Available fix options.
|
||||
/// </summary>
|
||||
public sealed record EvidenceFixes
|
||||
{
|
||||
public ImmutableArray<UpgradeFix> Upgrade { get; init; } = ImmutableArray<UpgradeFix>.Empty;
|
||||
public DistroBackport? DistroBackport { get; init; }
|
||||
public ImmutableArray<ConfigFix> Config { get; init; } = ImmutableArray<ConfigFix>.Empty;
|
||||
public ImmutableArray<ContainmentFix> Containment { get; init; } = ImmutableArray<ContainmentFix>.Empty;
|
||||
}
|
||||
|
||||
public sealed record UpgradeFix
|
||||
{
|
||||
public required string Version { get; init; }
|
||||
public DateTimeOffset? ReleaseDate { get; init; }
|
||||
public bool? BreakingChanges { get; init; }
|
||||
public string? Changelog { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DistroBackport
|
||||
{
|
||||
public bool Available { get; init; }
|
||||
public string? Advisory { get; init; }
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ConfigFix
|
||||
{
|
||||
public required string Option { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public string? Impact { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ContainmentFix
|
||||
{
|
||||
public required ContainmentType Type { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? Snippet { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ContainmentType
|
||||
{
|
||||
WafRule,
|
||||
Seccomp,
|
||||
Apparmor,
|
||||
NetworkPolicy,
|
||||
AdmissionController
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Organizational and operational context.
|
||||
/// </summary>
|
||||
public sealed record EvidenceContext
|
||||
{
|
||||
public string? TenantId { get; init; }
|
||||
public int? SlaDays { get; init; }
|
||||
public string? MaintenanceWindow { get; init; }
|
||||
public RiskAppetite? RiskAppetite { get; init; }
|
||||
public bool? AutoUpgradeAllowed { get; init; }
|
||||
public bool? ApprovalRequired { get; init; }
|
||||
public ImmutableArray<string> RequiredApprovers { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum RiskAppetite
|
||||
{
|
||||
Conservative,
|
||||
Moderate,
|
||||
Aggressive
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historical decisions from OpsMemory.
|
||||
/// </summary>
|
||||
public sealed record EvidenceOpsMemory
|
||||
{
|
||||
public ImmutableArray<SimilarDecision> SimilarDecisions { get; init; } = ImmutableArray<SimilarDecision>.Empty;
|
||||
public ImmutableArray<ApplicablePlaybook> ApplicablePlaybooks { get; init; } = ImmutableArray<ApplicablePlaybook>.Empty;
|
||||
public ImmutableArray<KnownIssue> KnownIssues { get; init; } = ImmutableArray<KnownIssue>.Empty;
|
||||
}
|
||||
|
||||
public sealed record SimilarDecision
|
||||
{
|
||||
public required string RecordId { get; init; }
|
||||
public required double Similarity { get; init; }
|
||||
public string? Decision { get; init; }
|
||||
public string? Outcome { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ApplicablePlaybook
|
||||
{
|
||||
public required string PlaybookId { get; init; }
|
||||
public required string Tactic { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
public sealed record KnownIssue
|
||||
{
|
||||
public required string IssueId { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? Resolution { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Engine version for reproducibility verification.
|
||||
/// </summary>
|
||||
public sealed record EvidenceEngineVersion
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public string? SourceDigest { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// <copyright file="AdvisoryChatResponseModels.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Structured response from Advisory AI Chat.
|
||||
/// All claims cite evidence links.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryChatResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Deterministic response ID: sha256(bundleId + intent + generatedAt).
|
||||
/// </summary>
|
||||
public required string ResponseId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input evidence bundle ID.
|
||||
/// </summary>
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected intent from user query.
|
||||
/// </summary>
|
||||
public required AdvisoryChatIntent Intent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC ISO-8601 timestamp of response generation.
|
||||
/// </summary>
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 2-3 sentence plain-language summary.
|
||||
/// </summary>
|
||||
public required string Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Impact analysis on the specific environment.
|
||||
/// </summary>
|
||||
public ImpactAssessment? Impact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability and exploitability assessment.
|
||||
/// </summary>
|
||||
public ReachabilityAssessment? ReachabilityAssessment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ranked mitigation options (safest first).
|
||||
/// </summary>
|
||||
public ImmutableArray<MitigationOption> Mitigations { get; init; } = ImmutableArray<MitigationOption>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// All evidence links cited in this response.
|
||||
/// </summary>
|
||||
public required ImmutableArray<EvidenceLink> EvidenceLinks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall response confidence.
|
||||
/// </summary>
|
||||
public required ConfidenceAssessment Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actions the user can take directly from this response.
|
||||
/// </summary>
|
||||
public ImmutableArray<ProposedAction> ProposedActions { get; init; } = ImmutableArray<ProposedAction>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Suggested follow-up questions or actions.
|
||||
/// </summary>
|
||||
public FollowUp? FollowUp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Audit metadata for this response.
|
||||
/// </summary>
|
||||
public ResponseAudit? Audit { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AdvisoryChatIntent
|
||||
{
|
||||
Explain,
|
||||
IsItReachable,
|
||||
DoWeHaveABackport,
|
||||
ProposeFix,
|
||||
Waive,
|
||||
BatchTriage,
|
||||
Compare,
|
||||
General
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Impact analysis on the specific environment.
|
||||
/// </summary>
|
||||
public sealed record ImpactAssessment
|
||||
{
|
||||
public string? Artifact { get; init; }
|
||||
public string? Environment { get; init; }
|
||||
public string? AffectedComponent { get; init; }
|
||||
public string? AffectedVersion { get; init; }
|
||||
public BlastRadiusInfo? BlastRadius { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BlastRadiusInfo
|
||||
{
|
||||
public int? Assets { get; init; }
|
||||
public int? Workloads { get; init; }
|
||||
public int? Namespaces { get; init; }
|
||||
public double? Percentage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability and exploitability assessment.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityAssessment
|
||||
{
|
||||
public ReachabilityStatus Status { get; init; } = ReachabilityStatus.Unknown;
|
||||
public int? CallgraphPaths { get; init; }
|
||||
public string? PathDescription { get; init; }
|
||||
public ImmutableArray<string> Guards { get; init; } = ImmutableArray<string>.Empty;
|
||||
public BinaryBackportInfo? BinaryBackport { get; init; }
|
||||
public ExploitPressureInfo? ExploitPressure { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BinaryBackportInfo
|
||||
{
|
||||
public bool Detected { get; init; }
|
||||
public string? Proof { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ExploitPressureInfo
|
||||
{
|
||||
public bool? Kev { get; init; }
|
||||
public double? EpssScore { get; init; }
|
||||
public double? EpssPercentile { get; init; }
|
||||
public ExploitMaturity? ExploitMaturity { get; init; }
|
||||
public string? Assessment { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ExploitMaturity
|
||||
{
|
||||
NotDefined,
|
||||
Unproven,
|
||||
Poc,
|
||||
Functional,
|
||||
High
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mitigation option ranked by safety.
|
||||
/// </summary>
|
||||
public sealed record MitigationOption
|
||||
{
|
||||
public required int Rank { get; init; }
|
||||
public required MitigationType Type { get; init; }
|
||||
public required string Label { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required MitigationRisk Risk { get; init; }
|
||||
public bool? Reversible { get; init; }
|
||||
public bool? BreakingChanges { get; init; }
|
||||
public bool? RequiresApproval { get; init; }
|
||||
public CodeSnippet? Snippet { get; init; }
|
||||
public CodeSnippet? Rollback { get; init; }
|
||||
public ImmutableArray<string> Prerequisites { get; init; } = ImmutableArray<string>.Empty;
|
||||
public string? EstimatedEffort { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum MitigationType
|
||||
{
|
||||
AcceptBackport,
|
||||
UpgradePackage,
|
||||
ConfigHardening,
|
||||
RuntimeContainment,
|
||||
Waiver,
|
||||
Defer,
|
||||
Escalate
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum MitigationRisk
|
||||
{
|
||||
Low,
|
||||
Medium,
|
||||
High
|
||||
}
|
||||
|
||||
public sealed record CodeSnippet
|
||||
{
|
||||
public string? Language { get; init; }
|
||||
public string? Code { get; init; }
|
||||
public string? Explanation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence link cited in the response.
|
||||
/// </summary>
|
||||
public sealed record EvidenceLink
|
||||
{
|
||||
public required EvidenceLinkType Type { get; init; }
|
||||
public required string Link { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public ConfidenceLevel? Confidence { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum EvidenceLinkType
|
||||
{
|
||||
Sbom,
|
||||
Vex,
|
||||
Reach,
|
||||
Binpatch,
|
||||
Attest,
|
||||
Policy,
|
||||
Runtime,
|
||||
Opsmem
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overall response confidence.
|
||||
/// </summary>
|
||||
public sealed record ConfidenceAssessment
|
||||
{
|
||||
public required ConfidenceLevel Level { get; init; }
|
||||
public required double Score { get; init; }
|
||||
public ImmutableArray<ConfidenceFactor> Factors { get; init; } = ImmutableArray<ConfidenceFactor>.Empty;
|
||||
public ImmutableArray<MissingEvidence> MissingEvidence { get; init; } = ImmutableArray<MissingEvidence>.Empty;
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ConfidenceLevel
|
||||
{
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
InsufficientEvidence
|
||||
}
|
||||
|
||||
public sealed record ConfidenceFactor
|
||||
{
|
||||
public string? Factor { get; init; }
|
||||
public ConfidenceImpact? Impact { get; init; }
|
||||
public double? Weight { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ConfidenceImpact
|
||||
{
|
||||
Positive,
|
||||
Negative
|
||||
}
|
||||
|
||||
public sealed record MissingEvidence
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? HowToObtain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action the user can take directly.
|
||||
/// </summary>
|
||||
public sealed record ProposedAction
|
||||
{
|
||||
public required string ActionId { get; init; }
|
||||
public required ProposedActionType ActionType { get; init; }
|
||||
public required string Label { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public ImmutableDictionary<string, string>? Parameters { get; init; }
|
||||
public bool? RequiresApproval { get; init; }
|
||||
public ActionRiskLevel? RiskLevel { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ProposedActionType
|
||||
{
|
||||
CreateVex,
|
||||
Approve,
|
||||
Quarantine,
|
||||
Defer,
|
||||
Waive,
|
||||
Escalate,
|
||||
GeneratePr,
|
||||
CreateTicket
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ActionRiskLevel
|
||||
{
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Suggested follow-up questions or actions.
|
||||
/// </summary>
|
||||
public sealed record FollowUp
|
||||
{
|
||||
public ImmutableArray<string> SuggestedQueries { get; init; } = ImmutableArray<string>.Empty;
|
||||
public ImmutableArray<RelatedFinding> RelatedFindings { get; init; } = ImmutableArray<RelatedFinding>.Empty;
|
||||
public ImmutableArray<string> NextSteps { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
public sealed record RelatedFinding
|
||||
{
|
||||
public string? FindingId { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit metadata for the response.
|
||||
/// </summary>
|
||||
public sealed record ResponseAudit
|
||||
{
|
||||
public string? ModelId { get; init; }
|
||||
public int? PromptTokens { get; init; }
|
||||
public int? CompletionTokens { get; init; }
|
||||
public int? TotalTokens { get; init; }
|
||||
public int? LatencyMs { get; init; }
|
||||
public ImmutableArray<string> GuardrailsApplied { get; init; } = ImmutableArray<string>.Empty;
|
||||
public int? RedactionsApplied { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// <copyright file="AdvisoryChatOptions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for Advisory Chat.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryChatOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "AdvisoryAI:Chat";
|
||||
|
||||
/// <summary>
|
||||
/// Enable/disable the Advisory Chat feature.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Inference configuration.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public InferenceOptions Inference { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Data provider configuration.
|
||||
/// </summary>
|
||||
public DataProviderOptions DataProviders { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Guardrail configuration.
|
||||
/// </summary>
|
||||
public GuardrailOptions Guardrails { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Audit logging configuration.
|
||||
/// </summary>
|
||||
public AuditOptions Audit { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inference client configuration.
|
||||
/// </summary>
|
||||
public sealed class InferenceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Inference provider: "claude", "openai", "ollama", "local".
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Provider { get; set; } = "claude";
|
||||
|
||||
/// <summary>
|
||||
/// Model identifier.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Model { get; set; } = "claude-sonnet-4-20250514";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum tokens in response.
|
||||
/// </summary>
|
||||
[Range(100, 16000)]
|
||||
public int MaxTokens { get; set; } = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// Temperature for sampling.
|
||||
/// </summary>
|
||||
[Range(0.0, 1.0)]
|
||||
public double Temperature { get; set; } = 0.3;
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout in seconds.
|
||||
/// </summary>
|
||||
[Range(10, 300)]
|
||||
public int TimeoutSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Base URL for the inference API.
|
||||
/// </summary>
|
||||
public string? BaseUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// API key secret name (for secret store lookup).
|
||||
/// </summary>
|
||||
public string? ApiKeySecret { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data provider configuration.
|
||||
/// </summary>
|
||||
public sealed class DataProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable VEX data provider.
|
||||
/// </summary>
|
||||
public bool VexEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enable SBOM data provider.
|
||||
/// </summary>
|
||||
public bool SbomEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enable reachability data provider.
|
||||
/// </summary>
|
||||
public bool ReachabilityEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enable binary patch data provider.
|
||||
/// </summary>
|
||||
public bool BinaryPatchEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enable OpsMemory data provider.
|
||||
/// </summary>
|
||||
public bool OpsMemoryEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enable policy data provider.
|
||||
/// </summary>
|
||||
public bool PolicyEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enable provenance data provider.
|
||||
/// </summary>
|
||||
public bool ProvenanceEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enable fix data provider.
|
||||
/// </summary>
|
||||
public bool FixEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enable context data provider.
|
||||
/// </summary>
|
||||
public bool ContextEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default timeout for data provider calls in seconds.
|
||||
/// </summary>
|
||||
[Range(1, 30)]
|
||||
public int DefaultTimeoutSeconds { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guardrail configuration.
|
||||
/// </summary>
|
||||
public sealed class GuardrailOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable guardrails.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum query length.
|
||||
/// </summary>
|
||||
[Range(1, 10000)]
|
||||
public int MaxQueryLength { get; set; } = 2000;
|
||||
|
||||
/// <summary>
|
||||
/// Require a CVE/GHSA reference in queries.
|
||||
/// </summary>
|
||||
public bool RequireFindingReference { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Enable PII detection.
|
||||
/// </summary>
|
||||
public bool DetectPii { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Block potentially harmful prompts.
|
||||
/// </summary>
|
||||
public bool BlockHarmfulPrompts { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit logging configuration.
|
||||
/// </summary>
|
||||
public sealed class AuditOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable audit logging.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include full evidence bundle in audit log.
|
||||
/// </summary>
|
||||
public bool IncludeEvidenceBundle { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for audit logs.
|
||||
/// </summary>
|
||||
public TimeSpan RetentionPeriod { get; set; } = TimeSpan.FromDays(90);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates AdvisoryChatOptions.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryChatOptionsValidator : IValidateOptions<AdvisoryChatOptions>
|
||||
{
|
||||
private static readonly string[] ValidProviders = ["claude", "openai", "ollama", "local"];
|
||||
|
||||
public ValidateOptionsResult Validate(string? name, AdvisoryChatOptions options)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (options.Enabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Inference.Provider))
|
||||
{
|
||||
errors.Add("Inference.Provider is required when Chat is enabled");
|
||||
}
|
||||
else if (!ValidProviders.Contains(options.Inference.Provider, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"Inference.Provider must be one of: {string.Join(", ", ValidProviders)}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Inference.Model))
|
||||
{
|
||||
errors.Add("Inference.Model is required when Chat is enabled");
|
||||
}
|
||||
|
||||
if (options.Inference.MaxTokens < 100 || options.Inference.MaxTokens > 16000)
|
||||
{
|
||||
errors.Add("Inference.MaxTokens must be between 100 and 16000");
|
||||
}
|
||||
|
||||
if (options.Inference.Temperature < 0.0 || options.Inference.Temperature > 1.0)
|
||||
{
|
||||
errors.Add("Inference.Temperature must be between 0.0 and 1.0");
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? ValidateOptionsResult.Fail(errors)
|
||||
: ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// <copyright file="AdvisoryChatIntentRouter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Chat.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Routing;
|
||||
|
||||
/// <summary>
|
||||
/// Routes user queries to appropriate intents based on slash commands or content analysis.
|
||||
/// </summary>
|
||||
public interface IAdvisoryChatIntentRouter
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses user input and extracts intent with parameters.
|
||||
/// </summary>
|
||||
/// <param name="userInput">Raw user input (may contain slash commands).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Parsed intent with extracted parameters.</returns>
|
||||
Task<IntentRoutingResult> RouteAsync(string userInput, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of intent routing.
|
||||
/// </summary>
|
||||
public sealed record IntentRoutingResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Detected intent.
|
||||
/// </summary>
|
||||
public required AdvisoryChatIntent Intent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence in intent detection (0-1).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Extracted parameters from the query.
|
||||
/// </summary>
|
||||
public required IntentParameters Parameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original user input (after normalization).
|
||||
/// </summary>
|
||||
public required string NormalizedInput { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a slash command was explicitly used.
|
||||
/// </summary>
|
||||
public bool ExplicitSlashCommand { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parameters extracted from user query.
|
||||
/// </summary>
|
||||
public sealed record IntentParameters
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE or finding ID (CVE-YYYY-NNNNN, GHSA-xxx).
|
||||
/// </summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image reference or digest.
|
||||
/// </summary>
|
||||
public string? ImageReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment name.
|
||||
/// </summary>
|
||||
public string? Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package PURL or name.
|
||||
/// </summary>
|
||||
public string? Package { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration for waivers.
|
||||
/// </summary>
|
||||
public string? Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for waivers.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top N for batch operations.
|
||||
/// </summary>
|
||||
public int? TopN { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Priority method for batch triage.
|
||||
/// </summary>
|
||||
public string? PriorityMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// First environment for comparison.
|
||||
/// </summary>
|
||||
public string? Environment1 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Second environment for comparison.
|
||||
/// </summary>
|
||||
public string? Environment2 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional parameters not captured by specific fields.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> AdditionalParameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of intent router.
|
||||
/// </summary>
|
||||
internal sealed partial class AdvisoryChatIntentRouter : IAdvisoryChatIntentRouter
|
||||
{
|
||||
private readonly ILogger<AdvisoryChatIntentRouter> _logger;
|
||||
|
||||
// Regex patterns for slash commands - compiled for performance
|
||||
[GeneratedRegex(@"^/explain\s+(?<finding>CVE-\d{4}-\d+|GHSA-[a-z0-9-]+)\s+in\s+(?<image>\S+)\s+(?<env>\S+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ExplainPattern();
|
||||
|
||||
[GeneratedRegex(@"^/is[_-]?it[_-]?reachable\s+(?<finding>CVE-\d{4}-\d+|GHSA-[a-z0-9-]+|[^@\s]+)\s+in\s+(?<image>\S+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ReachablePattern();
|
||||
|
||||
[GeneratedRegex(@"^/do[_-]?we[_-]?have[_-]?a[_-]?backport\s+(?<finding>CVE-\d{4}-\d+|GHSA-[a-z0-9-]+)\s+in\s+(?<package>\S+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex BackportPattern();
|
||||
|
||||
[GeneratedRegex(@"^/propose[_-]?fix\s+(?<finding>CVE-\d{4}-\d+|GHSA-[a-z0-9-]+|\S+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ProposeFixPattern();
|
||||
|
||||
[GeneratedRegex(@"^/waive\s+(?<finding>CVE-\d{4}-\d+|GHSA-[a-z0-9-]+|\S+)\s+for\s+(?<duration>\d+[dhwm])\s+because\s+(?<reason>.+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex WaivePattern();
|
||||
|
||||
[GeneratedRegex(@"^/batch[_-]?triage\s+(?:top\s+)?(?<top>\d+)\s+(?:findings\s+)?in\s+(?<env>\S+)(?:\s+by\s+(?<method>\S+))?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex BatchTriagePattern();
|
||||
|
||||
[GeneratedRegex(@"^/compare\s+(?<env1>\S+)\s+vs\s+(?<env2>\S+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ComparePattern();
|
||||
|
||||
// Patterns for CVE/GHSA extraction
|
||||
[GeneratedRegex(@"CVE-\d{4}-\d+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex CvePattern();
|
||||
|
||||
[GeneratedRegex(@"GHSA-[a-z0-9-]+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex GhsaPattern();
|
||||
|
||||
// Image reference pattern
|
||||
[GeneratedRegex(@"(?<image>(?:[a-zA-Z0-9][\w.-]*(?:\.[a-zA-Z0-9][\w.-]*)*(?::\d+)?/)?[\w.-]+/[\w.-]+(?:@sha256:[a-f0-9]{64}|:[a-zA-Z0-9][\w.-]*))", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ImagePattern();
|
||||
|
||||
public AdvisoryChatIntentRouter(ILogger<AdvisoryChatIntentRouter> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IntentRoutingResult> RouteAsync(string userInput, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(userInput);
|
||||
|
||||
var normalized = userInput.Trim();
|
||||
|
||||
_logger.LogDebug("Routing intent for input: {Input}", TruncateForLog(normalized));
|
||||
|
||||
// Try explicit slash commands first
|
||||
if (normalized.StartsWith('/'))
|
||||
{
|
||||
var slashResult = TryParseSlashCommand(normalized);
|
||||
if (slashResult is not null)
|
||||
{
|
||||
_logger.LogInformation("Detected explicit slash command: {Intent}", slashResult.Intent);
|
||||
return Task.FromResult(slashResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to content-based intent detection
|
||||
var inferredResult = InferIntentFromContent(normalized);
|
||||
|
||||
_logger.LogInformation("Inferred intent: {Intent} (confidence: {Confidence:F2})",
|
||||
inferredResult.Intent, inferredResult.Confidence);
|
||||
|
||||
return Task.FromResult(inferredResult);
|
||||
}
|
||||
|
||||
private IntentRoutingResult? TryParseSlashCommand(string input)
|
||||
{
|
||||
// /explain {CVE} in {image} {environment}
|
||||
var explainMatch = ExplainPattern().Match(input);
|
||||
if (explainMatch.Success)
|
||||
{
|
||||
return new IntentRoutingResult
|
||||
{
|
||||
Intent = AdvisoryChatIntent.Explain,
|
||||
Confidence = 1.0,
|
||||
ExplicitSlashCommand = true,
|
||||
NormalizedInput = input,
|
||||
Parameters = new IntentParameters
|
||||
{
|
||||
FindingId = explainMatch.Groups["finding"].Value.ToUpperInvariant(),
|
||||
ImageReference = explainMatch.Groups["image"].Value,
|
||||
Environment = explainMatch.Groups["env"].Value
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// /is-it-reachable {CVE|component} in {image}
|
||||
var reachableMatch = ReachablePattern().Match(input);
|
||||
if (reachableMatch.Success)
|
||||
{
|
||||
var finding = reachableMatch.Groups["finding"].Value;
|
||||
var isCve = CvePattern().IsMatch(finding) || GhsaPattern().IsMatch(finding);
|
||||
|
||||
return new IntentRoutingResult
|
||||
{
|
||||
Intent = AdvisoryChatIntent.IsItReachable,
|
||||
Confidence = 1.0,
|
||||
ExplicitSlashCommand = true,
|
||||
NormalizedInput = input,
|
||||
Parameters = new IntentParameters
|
||||
{
|
||||
FindingId = isCve ? finding.ToUpperInvariant() : null,
|
||||
Package = isCve ? null : finding,
|
||||
ImageReference = reachableMatch.Groups["image"].Value
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// /do-we-have-a-backport {CVE} in {component}
|
||||
var backportMatch = BackportPattern().Match(input);
|
||||
if (backportMatch.Success)
|
||||
{
|
||||
return new IntentRoutingResult
|
||||
{
|
||||
Intent = AdvisoryChatIntent.DoWeHaveABackport,
|
||||
Confidence = 1.0,
|
||||
ExplicitSlashCommand = true,
|
||||
NormalizedInput = input,
|
||||
Parameters = new IntentParameters
|
||||
{
|
||||
FindingId = backportMatch.Groups["finding"].Value.ToUpperInvariant(),
|
||||
Package = backportMatch.Groups["package"].Value
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// /propose-fix {CVE|finding}
|
||||
var proposeFixMatch = ProposeFixPattern().Match(input);
|
||||
if (proposeFixMatch.Success)
|
||||
{
|
||||
return new IntentRoutingResult
|
||||
{
|
||||
Intent = AdvisoryChatIntent.ProposeFix,
|
||||
Confidence = 1.0,
|
||||
ExplicitSlashCommand = true,
|
||||
NormalizedInput = input,
|
||||
Parameters = new IntentParameters
|
||||
{
|
||||
FindingId = proposeFixMatch.Groups["finding"].Value.ToUpperInvariant()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// /waive {CVE} for {duration} because {reason}
|
||||
var waiveMatch = WaivePattern().Match(input);
|
||||
if (waiveMatch.Success)
|
||||
{
|
||||
return new IntentRoutingResult
|
||||
{
|
||||
Intent = AdvisoryChatIntent.Waive,
|
||||
Confidence = 1.0,
|
||||
ExplicitSlashCommand = true,
|
||||
NormalizedInput = input,
|
||||
Parameters = new IntentParameters
|
||||
{
|
||||
FindingId = waiveMatch.Groups["finding"].Value.ToUpperInvariant(),
|
||||
Duration = waiveMatch.Groups["duration"].Value,
|
||||
Reason = waiveMatch.Groups["reason"].Value
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// /batch-triage top N findings in {environment} by {method}
|
||||
var batchMatch = BatchTriagePattern().Match(input);
|
||||
if (batchMatch.Success)
|
||||
{
|
||||
_ = int.TryParse(batchMatch.Groups["top"].Value, out var topN);
|
||||
return new IntentRoutingResult
|
||||
{
|
||||
Intent = AdvisoryChatIntent.BatchTriage,
|
||||
Confidence = 1.0,
|
||||
ExplicitSlashCommand = true,
|
||||
NormalizedInput = input,
|
||||
Parameters = new IntentParameters
|
||||
{
|
||||
TopN = topN > 0 ? topN : 10,
|
||||
Environment = batchMatch.Groups["env"].Value,
|
||||
PriorityMethod = batchMatch.Groups["method"].Success
|
||||
? batchMatch.Groups["method"].Value
|
||||
: "exploit_pressure"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// /compare {env1} vs {env2}
|
||||
var compareMatch = ComparePattern().Match(input);
|
||||
if (compareMatch.Success)
|
||||
{
|
||||
return new IntentRoutingResult
|
||||
{
|
||||
Intent = AdvisoryChatIntent.Compare,
|
||||
Confidence = 1.0,
|
||||
ExplicitSlashCommand = true,
|
||||
NormalizedInput = input,
|
||||
Parameters = new IntentParameters
|
||||
{
|
||||
Environment1 = compareMatch.Groups["env1"].Value,
|
||||
Environment2 = compareMatch.Groups["env2"].Value
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private IntentRoutingResult InferIntentFromContent(string input)
|
||||
{
|
||||
var lowerInput = input.ToLowerInvariant();
|
||||
var parameters = ExtractParametersFromContent(input);
|
||||
|
||||
// Keywords for each intent
|
||||
var explainKeywords = new[] { "explain", "what does", "what is", "tell me about", "describe", "mean" };
|
||||
var reachableKeywords = new[] { "reachable", "reach", "call", "path", "accessible", "executed" };
|
||||
var backportKeywords = new[] { "backport", "patch", "binary", "distro fix", "security update" };
|
||||
var fixKeywords = new[] { "fix", "remediate", "resolve", "mitigate", "patch", "upgrade", "update" };
|
||||
var waiveKeywords = new[] { "waive", "accept risk", "exception", "defer", "skip" };
|
||||
var triageKeywords = new[] { "triage", "prioritize", "batch", "top", "most important", "critical" };
|
||||
var compareKeywords = new[] { "compare", "difference", "vs", "versus", "between" };
|
||||
|
||||
// Score each intent
|
||||
var scores = new Dictionary<AdvisoryChatIntent, double>
|
||||
{
|
||||
[AdvisoryChatIntent.Explain] = ScoreKeywords(lowerInput, explainKeywords),
|
||||
[AdvisoryChatIntent.IsItReachable] = ScoreKeywords(lowerInput, reachableKeywords),
|
||||
[AdvisoryChatIntent.DoWeHaveABackport] = ScoreKeywords(lowerInput, backportKeywords),
|
||||
[AdvisoryChatIntent.ProposeFix] = ScoreKeywords(lowerInput, fixKeywords),
|
||||
[AdvisoryChatIntent.Waive] = ScoreKeywords(lowerInput, waiveKeywords),
|
||||
[AdvisoryChatIntent.BatchTriage] = ScoreKeywords(lowerInput, triageKeywords),
|
||||
[AdvisoryChatIntent.Compare] = ScoreKeywords(lowerInput, compareKeywords)
|
||||
};
|
||||
|
||||
// Find best match
|
||||
var (bestIntent, bestScore) = scores
|
||||
.OrderByDescending(kv => kv.Value)
|
||||
.First();
|
||||
|
||||
// If no strong signal, default to Explain if we have a CVE, otherwise General
|
||||
if (bestScore < 0.3)
|
||||
{
|
||||
if (parameters.FindingId is not null)
|
||||
{
|
||||
return new IntentRoutingResult
|
||||
{
|
||||
Intent = AdvisoryChatIntent.Explain,
|
||||
Confidence = 0.5,
|
||||
ExplicitSlashCommand = false,
|
||||
NormalizedInput = input,
|
||||
Parameters = parameters
|
||||
};
|
||||
}
|
||||
|
||||
return new IntentRoutingResult
|
||||
{
|
||||
Intent = AdvisoryChatIntent.General,
|
||||
Confidence = 0.3,
|
||||
ExplicitSlashCommand = false,
|
||||
NormalizedInput = input,
|
||||
Parameters = parameters
|
||||
};
|
||||
}
|
||||
|
||||
return new IntentRoutingResult
|
||||
{
|
||||
Intent = bestIntent,
|
||||
Confidence = Math.Min(bestScore + 0.3, 0.95), // Cap at 0.95 for inferred intents
|
||||
ExplicitSlashCommand = false,
|
||||
NormalizedInput = input,
|
||||
Parameters = parameters
|
||||
};
|
||||
}
|
||||
|
||||
private IntentParameters ExtractParametersFromContent(string input)
|
||||
{
|
||||
string? findingId = null;
|
||||
string? imageRef = null;
|
||||
|
||||
// Extract CVE
|
||||
var cveMatch = CvePattern().Match(input);
|
||||
if (cveMatch.Success)
|
||||
{
|
||||
findingId = cveMatch.Value.ToUpperInvariant();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try GHSA
|
||||
var ghsaMatch = GhsaPattern().Match(input);
|
||||
if (ghsaMatch.Success)
|
||||
{
|
||||
findingId = ghsaMatch.Value.ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
// Extract image reference
|
||||
var imageMatch = ImagePattern().Match(input);
|
||||
if (imageMatch.Success)
|
||||
{
|
||||
imageRef = imageMatch.Groups["image"].Value;
|
||||
}
|
||||
|
||||
return new IntentParameters
|
||||
{
|
||||
FindingId = findingId,
|
||||
ImageReference = imageRef
|
||||
};
|
||||
}
|
||||
|
||||
private static double ScoreKeywords(string input, string[] keywords)
|
||||
{
|
||||
var matches = keywords.Count(keyword => input.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||
return matches / (double)keywords.Length;
|
||||
}
|
||||
|
||||
private static string TruncateForLog(string input)
|
||||
{
|
||||
const int maxLength = 100;
|
||||
return input.Length <= maxLength ? input : input[..maxLength] + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://stella-ops.org/schemas/advisory-chat/evidence-bundle/v1",
|
||||
"title": "Advisory Chat Evidence Bundle",
|
||||
"description": "Input evidence bundle for Advisory AI Chat grounding. All data from Stella objects, no external sources.",
|
||||
"type": "object",
|
||||
"required": ["bundleId", "artifact", "finding", "assembledAt"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"bundleId": {
|
||||
"type": "string",
|
||||
"description": "Deterministic bundle ID: sha256(artifact.digest + finding.id + assembledAt)",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
},
|
||||
"assembledAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "UTC ISO-8601 timestamp when bundle was assembled"
|
||||
},
|
||||
"artifact": {
|
||||
"$ref": "#/$defs/artifact"
|
||||
},
|
||||
"finding": {
|
||||
"$ref": "#/$defs/finding"
|
||||
},
|
||||
"verdicts": {
|
||||
"$ref": "#/$defs/verdicts"
|
||||
},
|
||||
"reachability": {
|
||||
"$ref": "#/$defs/reachability"
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "#/$defs/provenance"
|
||||
},
|
||||
"fixes": {
|
||||
"$ref": "#/$defs/fixes"
|
||||
},
|
||||
"context": {
|
||||
"$ref": "#/$defs/context"
|
||||
},
|
||||
"opsMemory": {
|
||||
"$ref": "#/$defs/opsMemory"
|
||||
},
|
||||
"engineVersion": {
|
||||
"$ref": "#/$defs/engineVersion"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"artifact": {
|
||||
"type": "object",
|
||||
"description": "The artifact (container image) being analyzed",
|
||||
"required": ["digest", "environment"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "string",
|
||||
"description": "Full image reference (registry/repo:tag)",
|
||||
"examples": ["ghcr.io/acme/payments:v2.3.1"]
|
||||
},
|
||||
"digest": {
|
||||
"type": "string",
|
||||
"description": "Image digest (sha256)",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
},
|
||||
"environment": {
|
||||
"type": "string",
|
||||
"description": "Deployment environment",
|
||||
"examples": ["prod-eu1", "staging-us2", "dev"]
|
||||
},
|
||||
"sbomDigest": {
|
||||
"type": "string",
|
||||
"description": "SBOM document digest",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
},
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"description": "Image labels (sorted by key)",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"finding": {
|
||||
"type": "object",
|
||||
"description": "The specific finding being analyzed",
|
||||
"required": ["type", "id"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["cve", "ghsa", "policy_violation", "secret_exposure", "misconfiguration"],
|
||||
"description": "Finding type"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Finding identifier (CVE-YYYY-NNNNN, GHSA-xxxx, policy rule ID)",
|
||||
"examples": ["CVE-2024-12345", "GHSA-abcd-1234-efgh", "PE-002"]
|
||||
},
|
||||
"package": {
|
||||
"type": "string",
|
||||
"description": "Affected package PURL",
|
||||
"examples": ["pkg:deb/debian/openssl@3.0.12-1"]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Affected version"
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["unknown", "none", "low", "medium", "high", "critical"],
|
||||
"description": "Severity rating"
|
||||
},
|
||||
"cvssScore": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 10,
|
||||
"description": "CVSS base score"
|
||||
},
|
||||
"epssScore": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "EPSS exploitation probability score"
|
||||
},
|
||||
"kev": {
|
||||
"type": "boolean",
|
||||
"description": "In CISA Known Exploited Vulnerabilities catalog"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Vulnerability description from advisory"
|
||||
},
|
||||
"detectedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When finding was first detected"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verdicts": {
|
||||
"type": "object",
|
||||
"description": "VEX and policy verdicts",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"vex": {
|
||||
"type": "object",
|
||||
"description": "VEX consensus verdict",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["affected", "not_affected", "fixed", "under_investigation", "unknown"],
|
||||
"description": "Consensus VEX status"
|
||||
},
|
||||
"justification": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"component_not_present",
|
||||
"vulnerable_code_not_present",
|
||||
"vulnerable_code_not_in_execute_path",
|
||||
"vulnerable_code_cannot_be_controlled_by_adversary",
|
||||
"inline_mitigations_already_exist"
|
||||
],
|
||||
"description": "Justification for not_affected status"
|
||||
},
|
||||
"confidenceScore": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Consensus confidence (0-1)"
|
||||
},
|
||||
"consensusOutcome": {
|
||||
"type": "string",
|
||||
"enum": ["unanimous", "majority", "plurality", "conflict_resolved"],
|
||||
"description": "How consensus was reached"
|
||||
},
|
||||
"observations": {
|
||||
"type": "array",
|
||||
"description": "Contributing VEX observations (ordered by providerId)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["observationId", "providerId", "status"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"observationId": {
|
||||
"type": "string",
|
||||
"description": "Observation identifier"
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string",
|
||||
"description": "VEX provider (lowercase)",
|
||||
"examples": ["debian-security", "ubuntu-vex", "redhat-product-security"]
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["affected", "not_affected", "fixed", "under_investigation"]
|
||||
},
|
||||
"justification": {
|
||||
"type": "string"
|
||||
},
|
||||
"confidenceScore": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linksetId": {
|
||||
"type": "string",
|
||||
"description": "VEX linkset ID for evidence linking",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
"type": "array",
|
||||
"description": "Policy evaluation results (ordered by policyId)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["policyId", "decision"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"policyId": {
|
||||
"type": "string",
|
||||
"description": "Policy rule identifier",
|
||||
"examples": ["PE-002", "BLOCK-CRITICAL-CVE"]
|
||||
},
|
||||
"decision": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "warn", "block"],
|
||||
"description": "Policy decision"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Human-readable reason"
|
||||
},
|
||||
"k4Position": {
|
||||
"type": "string",
|
||||
"description": "K4 lattice position",
|
||||
"examples": ["bottom", "low", "medium", "high", "top"]
|
||||
},
|
||||
"evaluationId": {
|
||||
"type": "string",
|
||||
"description": "Evaluation trace ID for audit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"reachability": {
|
||||
"type": "object",
|
||||
"description": "Reachability analysis results",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["reachable", "unreachable", "conditional", "unknown"],
|
||||
"description": "Reachability verdict"
|
||||
},
|
||||
"confidenceScore": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Confidence in reachability verdict"
|
||||
},
|
||||
"callgraphPaths": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Number of call graph paths to vulnerable code"
|
||||
},
|
||||
"pathWitnesses": {
|
||||
"type": "array",
|
||||
"description": "Path witness IDs (ordered by witnessId)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["witnessId"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"witnessId": {
|
||||
"type": "string",
|
||||
"description": "Content-addressed path witness ID",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "string",
|
||||
"description": "Entry point symbol",
|
||||
"examples": ["main", "handleRequest", "ProcessPayment"]
|
||||
},
|
||||
"sink": {
|
||||
"type": "string",
|
||||
"description": "Vulnerable sink symbol",
|
||||
"examples": ["X509_verify_cert", "memcpy", "EVP_DecryptUpdate"]
|
||||
},
|
||||
"pathLength": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Call chain depth"
|
||||
},
|
||||
"guards": {
|
||||
"type": "array",
|
||||
"description": "Detected protective conditions",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"examples": ["null_check", "bounds_check", "auth_guard", "feature_flag"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"gates": {
|
||||
"type": "object",
|
||||
"description": "3-bit reachability gate (Smart-Diff model)",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reachable": {
|
||||
"type": ["boolean", "null"],
|
||||
"description": "Bit 0: Code is reachable"
|
||||
},
|
||||
"configActivated": {
|
||||
"type": ["boolean", "null"],
|
||||
"description": "Bit 1: Config enables vulnerable path"
|
||||
},
|
||||
"runningUser": {
|
||||
"type": ["boolean", "null"],
|
||||
"description": "Bit 2: Running user can trigger"
|
||||
},
|
||||
"gateClass": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 7,
|
||||
"description": "3-bit gate class (0-7)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runtimeHits": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Runtime sink hit observations"
|
||||
},
|
||||
"callgraphDigest": {
|
||||
"type": "string",
|
||||
"description": "Call graph snapshot digest for reproducibility",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
},
|
||||
"binaryPatch": {
|
||||
"type": "object",
|
||||
"description": "Binary backport detection result",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"detected": {
|
||||
"type": "boolean",
|
||||
"description": "Binary patch detected"
|
||||
},
|
||||
"proofId": {
|
||||
"type": "string",
|
||||
"description": "Backport proof identifier",
|
||||
"examples": ["bp-7f2a9e3"]
|
||||
},
|
||||
"matchMethod": {
|
||||
"type": "string",
|
||||
"enum": ["tlsh", "cfg_hash", "instruction_hash", "symbol_hash", "section_hash"],
|
||||
"description": "Fingerprint match method"
|
||||
},
|
||||
"similarity": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Fingerprint similarity score"
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Detection confidence"
|
||||
},
|
||||
"patchedSymbols": {
|
||||
"type": "array",
|
||||
"description": "Symbols confirmed patched",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"distroAdvisory": {
|
||||
"type": "string",
|
||||
"description": "Distro security advisory reference",
|
||||
"examples": ["DSA-5678", "USN-6789-1", "RHSA-2024:1234"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"type": "object",
|
||||
"description": "Artifact provenance and attestations",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"sbomAttestation": {
|
||||
"type": "object",
|
||||
"description": "SBOM DSSE attestation",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"dsseDigest": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
},
|
||||
"predicateType": {
|
||||
"type": "string",
|
||||
"examples": ["https://spdx.dev/Document", "https://cyclonedx.org/bom"]
|
||||
},
|
||||
"signatureValid": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"signerKeyId": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"buildProvenance": {
|
||||
"type": "object",
|
||||
"description": "Build provenance (SLSA)",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"dsseDigest": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
},
|
||||
"builder": {
|
||||
"type": "string",
|
||||
"examples": ["github-actions", "gitlab-ci", "tekton"]
|
||||
},
|
||||
"sourceRepo": {
|
||||
"type": "string"
|
||||
},
|
||||
"sourceCommit": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{40}$"
|
||||
},
|
||||
"slsaLevel": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
"rekorEntry": {
|
||||
"type": "object",
|
||||
"description": "Sigstore Rekor transparency log entry",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"uuid": {
|
||||
"type": "string"
|
||||
},
|
||||
"logIndex": {
|
||||
"type": "integer"
|
||||
},
|
||||
"integratedTime": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fixes": {
|
||||
"type": "object",
|
||||
"description": "Available fix options",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"upgrade": {
|
||||
"type": "array",
|
||||
"description": "Available package upgrades (ordered by version)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["version"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Fixed version"
|
||||
},
|
||||
"releaseDate": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"breakingChanges": {
|
||||
"type": "boolean",
|
||||
"description": "Contains breaking changes"
|
||||
},
|
||||
"changelog": {
|
||||
"type": "string",
|
||||
"description": "Changelog summary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"distroBackport": {
|
||||
"type": "object",
|
||||
"description": "Distro backport availability",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"available": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"advisory": {
|
||||
"type": "string",
|
||||
"examples": ["DSA-5678", "USN-6789-1"]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Backported package version"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"type": "array",
|
||||
"description": "Config hardening options",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["option", "description"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"option": {
|
||||
"type": "string",
|
||||
"description": "Config option or flag",
|
||||
"examples": ["disable_legacy_tls", "SSL_OP_NO_SSLv3"]
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"impact": {
|
||||
"type": "string",
|
||||
"description": "Potential impact of applying"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"containment": {
|
||||
"type": "array",
|
||||
"description": "Runtime containment options",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["waf_rule", "seccomp", "apparmor", "network_policy", "admission_controller"],
|
||||
"description": "Containment mechanism"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"snippet": {
|
||||
"type": "string",
|
||||
"description": "Ready-to-use config snippet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"type": "object",
|
||||
"description": "Organizational and operational context",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tenantId": {
|
||||
"type": "string"
|
||||
},
|
||||
"slaDays": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "SLA days remaining for remediation"
|
||||
},
|
||||
"maintenanceWindow": {
|
||||
"type": "string",
|
||||
"description": "Next maintenance window (cron or ISO-8601)",
|
||||
"examples": ["sun 02:00Z", "2024-12-15T02:00:00Z"]
|
||||
},
|
||||
"riskAppetite": {
|
||||
"type": "string",
|
||||
"enum": ["conservative", "moderate", "aggressive"],
|
||||
"description": "Org risk tolerance"
|
||||
},
|
||||
"autoUpgradeAllowed": {
|
||||
"type": "boolean",
|
||||
"description": "Auto-upgrade permitted for this env"
|
||||
},
|
||||
"approvalRequired": {
|
||||
"type": "boolean",
|
||||
"description": "Changes require approval workflow"
|
||||
},
|
||||
"requiredApprovers": {
|
||||
"type": "array",
|
||||
"description": "Roles required for approval",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"opsMemory": {
|
||||
"type": "object",
|
||||
"description": "Historical decisions from OpsMemory",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"similarDecisions": {
|
||||
"type": "array",
|
||||
"description": "Past decisions on similar findings (ordered by similarity)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["recordId", "similarity"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"recordId": {
|
||||
"type": "string"
|
||||
},
|
||||
"similarity": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"decision": {
|
||||
"type": "string",
|
||||
"examples": ["accepted", "mitigated", "waived", "escalated"]
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"description": "What happened after decision"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"applicablePlaybooks": {
|
||||
"type": "array",
|
||||
"description": "Matching playbook tactics",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["playbookId", "tactic"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"playbookId": {
|
||||
"type": "string"
|
||||
},
|
||||
"tactic": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"knownIssues": {
|
||||
"type": "array",
|
||||
"description": "Historical issues for this CVE/component",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["issueId"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"issueId": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"resolution": {
|
||||
"type": "string"
|
||||
},
|
||||
"resolvedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"engineVersion": {
|
||||
"type": "object",
|
||||
"description": "Engine version for reproducibility verification",
|
||||
"required": ["name", "version"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Engine name",
|
||||
"examples": ["AdvisoryChatEngine"]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Semantic version"
|
||||
},
|
||||
"sourceDigest": {
|
||||
"type": "string",
|
||||
"description": "SHA-256 of engine source/build",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://stella-ops.org/schemas/advisory-chat/response/v1",
|
||||
"title": "Advisory Chat Response",
|
||||
"description": "Structured output from Advisory AI Chat model. All claims must cite evidence links.",
|
||||
"type": "object",
|
||||
"required": ["responseId", "intent", "summary", "evidenceLinks", "confidence", "generatedAt"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"responseId": {
|
||||
"type": "string",
|
||||
"description": "Deterministic response ID: sha256(bundleId + intent + generatedAt)",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
},
|
||||
"bundleId": {
|
||||
"type": "string",
|
||||
"description": "Input evidence bundle ID",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
},
|
||||
"intent": {
|
||||
"type": "string",
|
||||
"description": "Detected intent from user query",
|
||||
"enum": [
|
||||
"explain",
|
||||
"is_it_reachable",
|
||||
"do_we_have_a_backport",
|
||||
"propose_fix",
|
||||
"waive",
|
||||
"batch_triage",
|
||||
"compare",
|
||||
"general"
|
||||
]
|
||||
},
|
||||
"generatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "UTC ISO-8601 timestamp of response generation"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "2-3 sentence plain-language summary",
|
||||
"maxLength": 500
|
||||
},
|
||||
"impact": {
|
||||
"type": "object",
|
||||
"description": "Impact analysis on the specific environment",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"artifact": {
|
||||
"type": "string",
|
||||
"description": "Image reference with digest"
|
||||
},
|
||||
"environment": {
|
||||
"type": "string"
|
||||
},
|
||||
"affectedComponent": {
|
||||
"type": "string",
|
||||
"description": "PURL of affected component"
|
||||
},
|
||||
"affectedVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"blastRadius": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"assets": {
|
||||
"type": "integer"
|
||||
},
|
||||
"workloads": {
|
||||
"type": "integer"
|
||||
},
|
||||
"namespaces": {
|
||||
"type": "integer"
|
||||
},
|
||||
"percentage": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Impact narrative"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reachabilityAssessment": {
|
||||
"type": "object",
|
||||
"description": "Reachability and exploitability assessment",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["reachable", "unreachable", "conditional", "unknown"]
|
||||
},
|
||||
"callgraphPaths": {
|
||||
"type": "integer",
|
||||
"description": "Number of paths to vulnerable code"
|
||||
},
|
||||
"pathDescription": {
|
||||
"type": "string",
|
||||
"description": "Narrative description of call paths"
|
||||
},
|
||||
"guards": {
|
||||
"type": "array",
|
||||
"description": "Protective conditions detected",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"binaryBackport": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"detected": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"proof": {
|
||||
"type": "string",
|
||||
"description": "Proof ID or evidence link"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exploitPressure": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kev": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"epssScore": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"epssPercentile": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"exploitMaturity": {
|
||||
"type": "string",
|
||||
"enum": ["not_defined", "unproven", "poc", "functional", "high"]
|
||||
},
|
||||
"assessment": {
|
||||
"type": "string",
|
||||
"description": "Human-readable exploit pressure assessment"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mitigations": {
|
||||
"type": "array",
|
||||
"description": "Ranked mitigation options (safest first)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["rank", "type", "label", "risk"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"rank": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Priority rank (1 = highest priority)"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"accept_backport",
|
||||
"upgrade_package",
|
||||
"config_hardening",
|
||||
"runtime_containment",
|
||||
"waiver",
|
||||
"defer",
|
||||
"escalate"
|
||||
]
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Short description",
|
||||
"examples": ["Accept distro backport", "Upgrade to openssl 3.0.15"]
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Detailed description of the mitigation"
|
||||
},
|
||||
"risk": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high"],
|
||||
"description": "Risk of applying this mitigation"
|
||||
},
|
||||
"reversible": {
|
||||
"type": "boolean",
|
||||
"description": "Can be rolled back"
|
||||
},
|
||||
"breakingChanges": {
|
||||
"type": "boolean",
|
||||
"description": "May cause breaking changes"
|
||||
},
|
||||
"requiresApproval": {
|
||||
"type": "boolean",
|
||||
"description": "Requires approval workflow"
|
||||
},
|
||||
"snippet": {
|
||||
"type": "object",
|
||||
"description": "Ready-to-execute code snippet",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"examples": ["bash", "dockerfile", "yaml", "json", "helmfile"]
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Executable code"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string",
|
||||
"description": "What the code does"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rollback": {
|
||||
"type": "object",
|
||||
"description": "Rollback procedure if needed",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string"
|
||||
},
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"prerequisites": {
|
||||
"type": "array",
|
||||
"description": "Requirements before applying",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"estimatedEffort": {
|
||||
"type": "string",
|
||||
"description": "Effort estimate",
|
||||
"examples": ["5 minutes", "1 hour", "requires testing cycle"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"evidenceLinks": {
|
||||
"type": "array",
|
||||
"description": "All evidence links cited in this response",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["type", "link", "description"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["sbom", "vex", "reach", "binpatch", "attest", "policy", "runtime", "opsmem"]
|
||||
},
|
||||
"link": {
|
||||
"type": "string",
|
||||
"description": "Evidence link in [type:path] format",
|
||||
"pattern": "^\\[.+\\]$"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "What this evidence shows"
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"confidence": {
|
||||
"type": "object",
|
||||
"description": "Overall response confidence",
|
||||
"required": ["level", "score"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"level": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low", "insufficient_evidence"]
|
||||
},
|
||||
"score": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Confidence score (0-1)"
|
||||
},
|
||||
"factors": {
|
||||
"type": "array",
|
||||
"description": "Factors affecting confidence",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"factor": {
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"multiple_vex_sources_agree",
|
||||
"callgraph_analysis_complete",
|
||||
"binary_backport_verified",
|
||||
"missing_runtime_data"
|
||||
]
|
||||
},
|
||||
"impact": {
|
||||
"type": "string",
|
||||
"enum": ["positive", "negative"]
|
||||
},
|
||||
"weight": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"missingEvidence": {
|
||||
"type": "array",
|
||||
"description": "Evidence that would increase confidence if available",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"howToObtain": {
|
||||
"type": "string",
|
||||
"description": "Instructions to gather this evidence"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"proposedActions": {
|
||||
"type": "array",
|
||||
"description": "Actions the user can take directly from this response",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["actionId", "actionType", "label"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"actionId": {
|
||||
"type": "string",
|
||||
"description": "Unique action identifier"
|
||||
},
|
||||
"actionType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"create_vex",
|
||||
"approve",
|
||||
"quarantine",
|
||||
"defer",
|
||||
"waive",
|
||||
"escalate",
|
||||
"generate_pr",
|
||||
"create_ticket"
|
||||
]
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Button label"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"description": "Pre-filled parameters for the action",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"requiresApproval": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"riskLevel": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high", "critical"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"followUp": {
|
||||
"type": "object",
|
||||
"description": "Suggested follow-up questions or actions",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"suggestedQueries": {
|
||||
"type": "array",
|
||||
"description": "Related queries the user might want to ask",
|
||||
"items": { "type": "string" },
|
||||
"maxItems": 5
|
||||
},
|
||||
"relatedFindings": {
|
||||
"type": "array",
|
||||
"description": "Related findings to investigate",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"findingId": {
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"nextSteps": {
|
||||
"type": "array",
|
||||
"description": "Recommended next steps",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"type": "object",
|
||||
"description": "Audit metadata for this response",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"modelId": {
|
||||
"type": "string",
|
||||
"description": "Model identifier used"
|
||||
},
|
||||
"promptTokens": {
|
||||
"type": "integer"
|
||||
},
|
||||
"completionTokens": {
|
||||
"type": "integer"
|
||||
},
|
||||
"totalTokens": {
|
||||
"type": "integer"
|
||||
},
|
||||
"latencyMs": {
|
||||
"type": "integer",
|
||||
"description": "Total response time in milliseconds"
|
||||
},
|
||||
"guardrailsApplied": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"redactionsApplied": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
// <copyright file="AdvisoryChatService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
using StellaOps.AdvisoryAI.Chat.Assembly;
|
||||
using StellaOps.AdvisoryAI.Chat.Routing;
|
||||
using StellaOps.AdvisoryAI.Guardrails;
|
||||
using StellaOps.AdvisoryAI.Prompting;
|
||||
|
||||
// Use namespace alias to avoid conflicts with types in parent StellaOps.AdvisoryAI.Chat namespace
|
||||
using Models = StellaOps.AdvisoryAI.Chat.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates Advisory AI Chat interactions.
|
||||
/// Assembles evidence bundles, routes intents, generates grounded responses,
|
||||
/// and ensures all suggested actions pass policy gates before rendering.
|
||||
/// </summary>
|
||||
public interface IAdvisoryChatService
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes a user query and generates an evidence-grounded response.
|
||||
/// </summary>
|
||||
/// <param name="request">Chat request with user query and context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Chat response with evidence links and proposed actions.</returns>
|
||||
Task<AdvisoryChatServiceResult> ProcessQueryAsync(
|
||||
AdvisoryChatRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to the Advisory Chat Service.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryChatRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant ID for multi-tenancy.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User ID making the request.
|
||||
/// </summary>
|
||||
public required string UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User roles for policy evaluation.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> UserRoles { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Raw user query (may contain slash commands).
|
||||
/// </summary>
|
||||
public required string Query { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact digest if context is already established.
|
||||
/// </summary>
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image reference if context is already established.
|
||||
/// </summary>
|
||||
public string? ImageReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment if context is already established.
|
||||
/// </summary>
|
||||
public string? Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for distributed tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Conversation ID for multi-turn context.
|
||||
/// </summary>
|
||||
public string? ConversationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from the Advisory Chat Service.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryChatServiceResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether processing succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The generated response (null if failed).
|
||||
/// </summary>
|
||||
public Models.AdvisoryChatResponse? Response { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if processing failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected intent from the query.
|
||||
/// </summary>
|
||||
public Models.AdvisoryChatIntent? Intent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether evidence bundle was successfully assembled.
|
||||
/// </summary>
|
||||
public bool EvidenceAssembled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether guardrails blocked the request.
|
||||
/// </summary>
|
||||
public bool GuardrailBlocked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Guardrail violations if blocked.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> GuardrailViolations { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Processing diagnostics.
|
||||
/// </summary>
|
||||
public AdvisoryChatDiagnostics? Diagnostics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processing diagnostics.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryChatDiagnostics
|
||||
{
|
||||
public long IntentRoutingMs { get; init; }
|
||||
public long EvidenceAssemblyMs { get; init; }
|
||||
public long GuardrailEvaluationMs { get; init; }
|
||||
public long InferenceMs { get; init; }
|
||||
public long PolicyGateMs { get; init; }
|
||||
public long TotalMs { get; init; }
|
||||
public int PromptTokens { get; init; }
|
||||
public int CompletionTokens { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of the Advisory Chat Service.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryChatService : IAdvisoryChatService
|
||||
{
|
||||
private readonly IAdvisoryChatIntentRouter _intentRouter;
|
||||
private readonly IEvidenceBundleAssembler _evidenceAssembler;
|
||||
private readonly IAdvisoryGuardrailPipeline _guardrails;
|
||||
private readonly IAdvisoryInferenceClient _inferenceClient;
|
||||
private readonly IActionPolicyGate _policyGate;
|
||||
private readonly IAdvisoryChatAuditLogger _auditLogger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AdvisoryChatService> _logger;
|
||||
private readonly AdvisoryChatServiceOptions _options;
|
||||
|
||||
public AdvisoryChatService(
|
||||
IAdvisoryChatIntentRouter intentRouter,
|
||||
IEvidenceBundleAssembler evidenceAssembler,
|
||||
IAdvisoryGuardrailPipeline guardrails,
|
||||
IAdvisoryInferenceClient inferenceClient,
|
||||
IActionPolicyGate policyGate,
|
||||
IAdvisoryChatAuditLogger auditLogger,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<AdvisoryChatServiceOptions> options,
|
||||
ILogger<AdvisoryChatService> logger)
|
||||
{
|
||||
_intentRouter = intentRouter ?? throw new ArgumentNullException(nameof(intentRouter));
|
||||
_evidenceAssembler = evidenceAssembler ?? throw new ArgumentNullException(nameof(evidenceAssembler));
|
||||
_guardrails = guardrails ?? throw new ArgumentNullException(nameof(guardrails));
|
||||
_inferenceClient = inferenceClient ?? throw new ArgumentNullException(nameof(inferenceClient));
|
||||
_policyGate = policyGate ?? throw new ArgumentNullException(nameof(policyGate));
|
||||
_auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options?.Value ?? new AdvisoryChatServiceOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AdvisoryChatServiceResult> ProcessQueryAsync(
|
||||
AdvisoryChatRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var totalStopwatch = Stopwatch.StartNew();
|
||||
var diagnostics = new AdvisoryChatDiagnosticsBuilder();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Processing advisory chat query for tenant {TenantId} user {UserId}",
|
||||
request.TenantId, request.UserId);
|
||||
|
||||
try
|
||||
{
|
||||
// Phase 1: Route intent
|
||||
var intentStopwatch = Stopwatch.StartNew();
|
||||
var routingResult = await _intentRouter.RouteAsync(request.Query, cancellationToken);
|
||||
diagnostics.IntentRoutingMs = intentStopwatch.ElapsedMilliseconds;
|
||||
|
||||
_logger.LogDebug("Intent routing completed: {Intent} (confidence: {Confidence:F2})",
|
||||
routingResult.Intent, routingResult.Confidence);
|
||||
|
||||
// Phase 2: Validate we have enough context
|
||||
var (artifactDigest, findingId, environment) = ResolveContext(request, routingResult.Parameters);
|
||||
|
||||
if (string.IsNullOrEmpty(artifactDigest) || string.IsNullOrEmpty(findingId))
|
||||
{
|
||||
return CreateMissingContextResult(routingResult.Intent, artifactDigest, findingId);
|
||||
}
|
||||
|
||||
// Phase 3: Assemble evidence bundle
|
||||
var assemblyStopwatch = Stopwatch.StartNew();
|
||||
var assemblyResult = await _evidenceAssembler.AssembleAsync(
|
||||
new EvidenceBundleAssemblyRequest
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
ArtifactDigest = artifactDigest,
|
||||
ImageReference = request.ImageReference ?? routingResult.Parameters.ImageReference,
|
||||
Environment = environment ?? "unknown",
|
||||
FindingId = findingId,
|
||||
PackagePurl = routingResult.Parameters.Package,
|
||||
CorrelationId = request.CorrelationId
|
||||
},
|
||||
cancellationToken);
|
||||
diagnostics.EvidenceAssemblyMs = assemblyStopwatch.ElapsedMilliseconds;
|
||||
|
||||
if (!assemblyResult.Success || assemblyResult.Bundle is null)
|
||||
{
|
||||
return new AdvisoryChatServiceResult
|
||||
{
|
||||
Success = false,
|
||||
Error = assemblyResult.Error ?? "Failed to assemble evidence bundle",
|
||||
Intent = routingResult.Intent,
|
||||
EvidenceAssembled = false
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 4: Build prompt and run guardrails
|
||||
var guardrailStopwatch = Stopwatch.StartNew();
|
||||
var prompt = BuildPrompt(assemblyResult.Bundle, routingResult);
|
||||
var guardrailResult = await _guardrails.EvaluateAsync(prompt, cancellationToken);
|
||||
diagnostics.GuardrailEvaluationMs = guardrailStopwatch.ElapsedMilliseconds;
|
||||
|
||||
if (guardrailResult.Blocked)
|
||||
{
|
||||
_logger.LogWarning("Guardrails blocked query: {Violations}",
|
||||
string.Join(", ", guardrailResult.Violations.Select(v => v.Code)));
|
||||
|
||||
await _auditLogger.LogBlockedAsync(request, routingResult, guardrailResult, cancellationToken);
|
||||
|
||||
return new AdvisoryChatServiceResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Query blocked by guardrails",
|
||||
Intent = routingResult.Intent,
|
||||
EvidenceAssembled = true,
|
||||
GuardrailBlocked = true,
|
||||
GuardrailViolations = guardrailResult.Violations.Select(v => v.Message).ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 5: Call inference
|
||||
var inferenceStopwatch = Stopwatch.StartNew();
|
||||
var inferenceResult = await _inferenceClient.CompleteAsync(
|
||||
guardrailResult.SanitizedPrompt,
|
||||
new AdvisoryInferenceOptions
|
||||
{
|
||||
MaxTokens = _options.MaxCompletionTokens,
|
||||
Temperature = 0.1 // Low temperature for deterministic outputs
|
||||
},
|
||||
cancellationToken);
|
||||
diagnostics.InferenceMs = inferenceStopwatch.ElapsedMilliseconds;
|
||||
diagnostics.PromptTokens = inferenceResult.PromptTokens;
|
||||
diagnostics.CompletionTokens = inferenceResult.CompletionTokens;
|
||||
|
||||
// Phase 6: Parse and validate response
|
||||
var response = ParseInferenceResponse(
|
||||
inferenceResult.Completion,
|
||||
assemblyResult.Bundle,
|
||||
routingResult.Intent);
|
||||
|
||||
// Phase 7: Pre-check proposed actions against policy gate
|
||||
var policyStopwatch = Stopwatch.StartNew();
|
||||
response = await FilterProposedActionsByPolicyAsync(
|
||||
response, request, cancellationToken);
|
||||
diagnostics.PolicyGateMs = policyStopwatch.ElapsedMilliseconds;
|
||||
|
||||
totalStopwatch.Stop();
|
||||
diagnostics.TotalMs = totalStopwatch.ElapsedMilliseconds;
|
||||
|
||||
// Audit successful interaction
|
||||
await _auditLogger.LogSuccessAsync(request, routingResult, response, diagnostics.Build(), cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Advisory chat completed in {TotalMs}ms: {Intent} with {EvidenceLinks} evidence links",
|
||||
diagnostics.TotalMs, routingResult.Intent, response.EvidenceLinks.Length);
|
||||
|
||||
return new AdvisoryChatServiceResult
|
||||
{
|
||||
Success = true,
|
||||
Response = response,
|
||||
Intent = routingResult.Intent,
|
||||
EvidenceAssembled = true,
|
||||
Diagnostics = diagnostics.Build()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Advisory chat processing failed");
|
||||
|
||||
return new AdvisoryChatServiceResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Processing failed: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static (string? ArtifactDigest, string? FindingId, string? Environment) ResolveContext(
|
||||
AdvisoryChatRequest request, IntentParameters parameters)
|
||||
{
|
||||
var artifactDigest = request.ArtifactDigest ?? ExtractDigestFromImageRef(parameters.ImageReference);
|
||||
var findingId = parameters.FindingId;
|
||||
var environment = request.Environment ?? parameters.Environment;
|
||||
|
||||
return (artifactDigest, findingId, environment);
|
||||
}
|
||||
|
||||
private static string? ExtractDigestFromImageRef(string? imageRef)
|
||||
{
|
||||
if (string.IsNullOrEmpty(imageRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract sha256 digest if present
|
||||
var atIndex = imageRef.IndexOf('@');
|
||||
if (atIndex > 0 && imageRef.Length > atIndex + 1)
|
||||
{
|
||||
return imageRef[(atIndex + 1)..];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AdvisoryChatServiceResult CreateMissingContextResult(
|
||||
Models.AdvisoryChatIntent intent, string? artifactDigest, string? findingId)
|
||||
{
|
||||
var missing = new List<string>();
|
||||
if (string.IsNullOrEmpty(artifactDigest))
|
||||
{
|
||||
missing.Add("artifact digest or image reference");
|
||||
}
|
||||
if (string.IsNullOrEmpty(findingId))
|
||||
{
|
||||
missing.Add("CVE or finding ID");
|
||||
}
|
||||
|
||||
return new AdvisoryChatServiceResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Missing required context: {string.Join(", ", missing)}. " +
|
||||
"Please specify the artifact and finding in your query.",
|
||||
Intent = intent,
|
||||
EvidenceAssembled = false
|
||||
};
|
||||
}
|
||||
|
||||
private AdvisoryPrompt BuildPrompt(Models.AdvisoryChatEvidenceBundle bundle, IntentRoutingResult routing)
|
||||
{
|
||||
var promptJson = JsonSerializer.Serialize(bundle, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
// Create a citation for the bundle itself
|
||||
var citation = new AdvisoryPromptCitation(1, bundle.BundleId, "root");
|
||||
|
||||
return new AdvisoryPrompt(
|
||||
CacheKey: ComputePromptCacheKey(bundle.BundleId, routing.Intent),
|
||||
TaskType: Orchestration.AdvisoryTaskType.Remediation, // Default for chat
|
||||
Profile: "advisory-chat",
|
||||
Prompt: promptJson,
|
||||
Citations: ImmutableArray.Create(citation),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("intent", routing.Intent.ToString())
|
||||
.Add("confidence", routing.Confidence.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)),
|
||||
Diagnostics: ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
|
||||
private static string ComputePromptCacheKey(string bundleId, Models.AdvisoryChatIntent intent)
|
||||
{
|
||||
var input = $"{bundleId}|{intent}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(hash)[..16];
|
||||
}
|
||||
|
||||
private Models.AdvisoryChatResponse ParseInferenceResponse(
|
||||
string completion,
|
||||
Models.AdvisoryChatEvidenceBundle bundle,
|
||||
Models.AdvisoryChatIntent intent)
|
||||
{
|
||||
// In a real implementation, this would parse the structured JSON response from the model
|
||||
// For now, create a basic response structure
|
||||
var generatedAt = _timeProvider.GetUtcNow();
|
||||
var responseId = ComputeResponseId(bundle.BundleId, intent, generatedAt);
|
||||
|
||||
return new Models.AdvisoryChatResponse
|
||||
{
|
||||
ResponseId = responseId,
|
||||
BundleId = bundle.BundleId,
|
||||
Intent = intent,
|
||||
GeneratedAt = generatedAt,
|
||||
Summary = ExtractSummaryFromCompletion(completion),
|
||||
EvidenceLinks = ExtractEvidenceLinksFromBundle(bundle),
|
||||
Confidence = new Models.ConfidenceAssessment
|
||||
{
|
||||
Level = DetermineConfidenceLevel(bundle),
|
||||
Score = ComputeConfidenceScore(bundle)
|
||||
},
|
||||
Audit = new Models.ResponseAudit
|
||||
{
|
||||
ModelId = _options.ModelId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeResponseId(string bundleId, Models.AdvisoryChatIntent intent, DateTimeOffset generatedAt)
|
||||
{
|
||||
var input = $"{bundleId}|{intent}|{generatedAt:O}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static string ExtractSummaryFromCompletion(string completion)
|
||||
{
|
||||
// Extract first paragraph or up to 500 chars
|
||||
var lines = completion.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
var firstParagraph = string.Join(" ", lines.Take(3));
|
||||
return firstParagraph.Length > 500 ? firstParagraph[..500] + "..." : firstParagraph;
|
||||
}
|
||||
|
||||
private static ImmutableArray<Models.EvidenceLink> ExtractEvidenceLinksFromBundle(Models.AdvisoryChatEvidenceBundle bundle)
|
||||
{
|
||||
var links = new List<Models.EvidenceLink>();
|
||||
|
||||
// SBOM link
|
||||
if (bundle.Artifact?.SbomDigest is not null)
|
||||
{
|
||||
links.Add(new Models.EvidenceLink
|
||||
{
|
||||
Type = Models.EvidenceLinkType.Sbom,
|
||||
Link = $"[sbom:{bundle.Artifact.SbomDigest}]",
|
||||
Description = "SBOM for artifact",
|
||||
Confidence = Models.ConfidenceLevel.High
|
||||
});
|
||||
}
|
||||
|
||||
// VEX link
|
||||
if (bundle.Verdicts?.Vex?.LinksetId is not null)
|
||||
{
|
||||
links.Add(new Models.EvidenceLink
|
||||
{
|
||||
Type = Models.EvidenceLinkType.Vex,
|
||||
Link = $"[vex:{bundle.Verdicts.Vex.LinksetId}]",
|
||||
Description = $"VEX consensus: {bundle.Verdicts.Vex.Status}",
|
||||
Confidence = bundle.Verdicts.Vex.ConfidenceScore > 0.8
|
||||
? Models.ConfidenceLevel.High
|
||||
: Models.ConfidenceLevel.Medium
|
||||
});
|
||||
}
|
||||
|
||||
// Reachability link
|
||||
if (bundle.Reachability?.CallgraphDigest is not null)
|
||||
{
|
||||
links.Add(new Models.EvidenceLink
|
||||
{
|
||||
Type = Models.EvidenceLinkType.Reach,
|
||||
Link = $"[reach:{bundle.Reachability.CallgraphDigest}]",
|
||||
Description = $"Reachability: {bundle.Reachability.Status} ({bundle.Reachability.CallgraphPaths} paths)",
|
||||
Confidence = bundle.Reachability.ConfidenceScore > 0.8
|
||||
? Models.ConfidenceLevel.High
|
||||
: Models.ConfidenceLevel.Medium
|
||||
});
|
||||
}
|
||||
|
||||
// Binary patch link
|
||||
if (bundle.Reachability?.BinaryPatch?.Detected == true)
|
||||
{
|
||||
links.Add(new Models.EvidenceLink
|
||||
{
|
||||
Type = Models.EvidenceLinkType.Binpatch,
|
||||
Link = $"[binpatch:{bundle.Reachability.BinaryPatch.ProofId}]",
|
||||
Description = $"Binary backport detected: {bundle.Reachability.BinaryPatch.DistroAdvisory}",
|
||||
Confidence = bundle.Reachability.BinaryPatch.Confidence > 0.9
|
||||
? Models.ConfidenceLevel.High
|
||||
: Models.ConfidenceLevel.Medium
|
||||
});
|
||||
}
|
||||
|
||||
// Attestation link
|
||||
if (bundle.Provenance?.SbomAttestation?.DsseDigest is not null)
|
||||
{
|
||||
links.Add(new Models.EvidenceLink
|
||||
{
|
||||
Type = Models.EvidenceLinkType.Attest,
|
||||
Link = $"[attest:{bundle.Provenance.SbomAttestation.DsseDigest}]",
|
||||
Description = "SBOM attestation",
|
||||
Confidence = bundle.Provenance.SbomAttestation.SignatureValid == true
|
||||
? Models.ConfidenceLevel.High
|
||||
: Models.ConfidenceLevel.Low
|
||||
});
|
||||
}
|
||||
|
||||
return links.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static Models.ConfidenceLevel DetermineConfidenceLevel(Models.AdvisoryChatEvidenceBundle bundle)
|
||||
{
|
||||
var score = ComputeConfidenceScore(bundle);
|
||||
return score switch
|
||||
{
|
||||
>= 0.8 => Models.ConfidenceLevel.High,
|
||||
>= 0.5 => Models.ConfidenceLevel.Medium,
|
||||
>= 0.2 => Models.ConfidenceLevel.Low,
|
||||
_ => Models.ConfidenceLevel.InsufficientEvidence
|
||||
};
|
||||
}
|
||||
|
||||
private static double ComputeConfidenceScore(Models.AdvisoryChatEvidenceBundle bundle)
|
||||
{
|
||||
var score = 0.0;
|
||||
var factors = 0;
|
||||
|
||||
// VEX consensus
|
||||
if (bundle.Verdicts?.Vex is not null)
|
||||
{
|
||||
score += bundle.Verdicts.Vex.ConfidenceScore ?? 0.5;
|
||||
factors++;
|
||||
}
|
||||
|
||||
// Reachability analysis
|
||||
if (bundle.Reachability is not null)
|
||||
{
|
||||
score += bundle.Reachability.ConfidenceScore ?? 0.5;
|
||||
factors++;
|
||||
}
|
||||
|
||||
// Binary patch
|
||||
if (bundle.Reachability?.BinaryPatch?.Detected == true)
|
||||
{
|
||||
score += bundle.Reachability.BinaryPatch.Confidence ?? 0.7;
|
||||
factors++;
|
||||
}
|
||||
|
||||
// Provenance
|
||||
if (bundle.Provenance?.SbomAttestation?.SignatureValid == true)
|
||||
{
|
||||
score += 1.0;
|
||||
factors++;
|
||||
}
|
||||
|
||||
return factors > 0 ? score / factors : 0.0;
|
||||
}
|
||||
|
||||
private async Task<Models.AdvisoryChatResponse> FilterProposedActionsByPolicyAsync(
|
||||
Models.AdvisoryChatResponse response,
|
||||
AdvisoryChatRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.ProposedActions.IsDefaultOrEmpty)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
var filteredActions = new List<Models.ProposedAction>();
|
||||
|
||||
foreach (var action in response.ProposedActions)
|
||||
{
|
||||
var context = new ActionContext
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
UserId = request.UserId,
|
||||
UserRoles = request.UserRoles,
|
||||
Environment = request.Environment ?? "unknown",
|
||||
CorrelationId = request.CorrelationId
|
||||
};
|
||||
|
||||
var proposal = new ActionProposal
|
||||
{
|
||||
ProposalId = action.ActionId,
|
||||
ActionType = action.ActionType.ToString().ToLowerInvariant(),
|
||||
Label = action.Label,
|
||||
Parameters = action.Parameters ?? ImmutableDictionary<string, string>.Empty,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
var decision = await _policyGate.EvaluateAsync(proposal, context, cancellationToken);
|
||||
|
||||
if (decision.Decision != PolicyDecisionKind.Deny)
|
||||
{
|
||||
filteredActions.Add(action with
|
||||
{
|
||||
RequiresApproval = decision.Decision == PolicyDecisionKind.AllowWithApproval,
|
||||
RiskLevel = MapPolicyToRiskLevel(decision)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return response with { ProposedActions = filteredActions.ToImmutableArray() };
|
||||
}
|
||||
|
||||
private static Models.ActionRiskLevel MapPolicyToRiskLevel(ActionPolicyDecision decision)
|
||||
{
|
||||
return decision.PolicyId switch
|
||||
{
|
||||
"critical-risk-production" => Models.ActionRiskLevel.Critical,
|
||||
"high-risk-approval" or "high-risk-admin" => Models.ActionRiskLevel.High,
|
||||
"medium-risk-approval" or "medium-risk-elevated-role" => Models.ActionRiskLevel.Medium,
|
||||
_ => Models.ActionRiskLevel.Low
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class AdvisoryChatDiagnosticsBuilder
|
||||
{
|
||||
public long IntentRoutingMs { get; set; }
|
||||
public long EvidenceAssemblyMs { get; set; }
|
||||
public long GuardrailEvaluationMs { get; set; }
|
||||
public long InferenceMs { get; set; }
|
||||
public long PolicyGateMs { get; set; }
|
||||
public long TotalMs { get; set; }
|
||||
public int PromptTokens { get; set; }
|
||||
public int CompletionTokens { get; set; }
|
||||
|
||||
public AdvisoryChatDiagnostics Build() => new()
|
||||
{
|
||||
IntentRoutingMs = IntentRoutingMs,
|
||||
EvidenceAssemblyMs = EvidenceAssemblyMs,
|
||||
GuardrailEvaluationMs = GuardrailEvaluationMs,
|
||||
InferenceMs = InferenceMs,
|
||||
PolicyGateMs = PolicyGateMs,
|
||||
TotalMs = TotalMs,
|
||||
PromptTokens = PromptTokens,
|
||||
CompletionTokens = CompletionTokens
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for Advisory Chat Service.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryChatServiceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Model identifier for inference.
|
||||
/// </summary>
|
||||
public string ModelId { get; set; } = "advisory-chat-v1";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum completion tokens.
|
||||
/// </summary>
|
||||
public int MaxCompletionTokens { get; set; } = 2000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inference client interface for Advisory Chat.
|
||||
/// </summary>
|
||||
public interface IAdvisoryInferenceClient
|
||||
{
|
||||
Task<AdvisoryInferenceResult> CompleteAsync(
|
||||
string prompt,
|
||||
AdvisoryInferenceOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record AdvisoryInferenceOptions
|
||||
{
|
||||
public int MaxTokens { get; init; } = 2000;
|
||||
public double Temperature { get; init; } = 0.1;
|
||||
}
|
||||
|
||||
public sealed record AdvisoryInferenceResult
|
||||
{
|
||||
public required string Completion { get; init; }
|
||||
public int PromptTokens { get; init; }
|
||||
public int CompletionTokens { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit logger for Advisory Chat interactions.
|
||||
/// </summary>
|
||||
public interface IAdvisoryChatAuditLogger
|
||||
{
|
||||
Task LogSuccessAsync(
|
||||
AdvisoryChatRequest request,
|
||||
IntentRoutingResult routing,
|
||||
Models.AdvisoryChatResponse response,
|
||||
AdvisoryChatDiagnostics diagnostics,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task LogBlockedAsync(
|
||||
AdvisoryChatRequest request,
|
||||
IntentRoutingResult routing,
|
||||
AdvisoryGuardrailResult guardrailResult,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
Reference in New Issue
Block a user