feat: add security sink detection patterns for JavaScript/TypeScript

- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations).
- Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns.
- Added `package-lock.json` for dependency management.
This commit is contained in:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View File

@@ -0,0 +1,108 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
namespace StellaOps.Scanner.Explainability.Assumptions;
/// <summary>
/// Represents a single assumption made during vulnerability analysis.
/// Assumptions capture the conditions under which a finding is considered valid.
/// </summary>
/// <param name="Category">The category of assumption (compiler flag, runtime config, etc.)</param>
/// <param name="Key">The specific assumption key (e.g., "-fstack-protector", "DEBUG_MODE")</param>
/// <param name="AssumedValue">The value assumed during analysis</param>
/// <param name="ObservedValue">The actual observed value, if available</param>
/// <param name="Source">How the assumption was derived</param>
/// <param name="Confidence">The confidence level in this assumption</param>
public sealed record Assumption(
AssumptionCategory Category,
string Key,
string AssumedValue,
string? ObservedValue,
AssumptionSource Source,
ConfidenceLevel Confidence
)
{
/// <summary>
/// Returns true if the observed value matches the assumed value.
/// </summary>
public bool IsValidated => ObservedValue is not null &&
string.Equals(AssumedValue, ObservedValue, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Returns true if the observed value contradicts the assumed value.
/// </summary>
public bool IsContradicted => ObservedValue is not null &&
!string.Equals(AssumedValue, ObservedValue, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Categories of assumptions that affect vulnerability exploitability.
/// </summary>
public enum AssumptionCategory
{
/// <summary>Compiler flags like -fstack-protector, -D_FORTIFY_SOURCE</summary>
CompilerFlag,
/// <summary>Environment variables, config files, runtime settings</summary>
RuntimeConfig,
/// <summary>Feature flags, build variants, conditional compilation</summary>
FeatureGate,
/// <summary>LD_PRELOAD, RPATH, symbol versioning, loader behavior</summary>
LoaderBehavior,
/// <summary>Port bindings, firewall rules, network exposure</summary>
NetworkExposure,
/// <summary>Capabilities, seccomp profiles, AppArmor/SELinux policies</summary>
ProcessPrivilege,
/// <summary>Memory layout assumptions (ASLR, PIE)</summary>
MemoryProtection,
/// <summary>System call availability and filtering</summary>
SyscallAvailability
}
/// <summary>
/// How an assumption was derived.
/// </summary>
public enum AssumptionSource
{
/// <summary>Default assumption when no evidence available</summary>
Default,
/// <summary>Inferred from static analysis of binaries/code</summary>
StaticAnalysis,
/// <summary>Observed from runtime telemetry</summary>
RuntimeObservation,
/// <summary>Derived from container/image manifest</summary>
ImageManifest,
/// <summary>Provided by user configuration</summary>
UserProvided,
/// <summary>Extracted from Dockerfile or build configuration</summary>
BuildConfig
}
/// <summary>
/// Confidence level in an assumption.
/// </summary>
public enum ConfidenceLevel
{
/// <summary>No evidence, using defaults</summary>
Low = 1,
/// <summary>Some indirect evidence</summary>
Medium = 2,
/// <summary>Strong evidence from static analysis</summary>
High = 3,
/// <summary>Direct runtime observation</summary>
Verified = 4
}

View File

@@ -0,0 +1,119 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using System.Collections.Immutable;
namespace StellaOps.Scanner.Explainability.Assumptions;
/// <summary>
/// A collection of assumptions associated with a finding or analysis context.
/// Provides methods for querying and validating assumptions.
/// </summary>
public sealed record AssumptionSet
{
/// <summary>
/// The unique identifier for this assumption set.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// The assumptions in this set, keyed by category and key.
/// </summary>
public ImmutableArray<Assumption> Assumptions { get; init; } = [];
/// <summary>
/// When this assumption set was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Optional context identifier (e.g., finding ID, image digest).
/// </summary>
public string? ContextId { get; init; }
/// <summary>
/// Gets all assumptions of a specific category.
/// </summary>
public IEnumerable<Assumption> GetByCategory(AssumptionCategory category) =>
Assumptions.Where(a => a.Category == category);
/// <summary>
/// Gets a specific assumption by category and key.
/// </summary>
public Assumption? Get(AssumptionCategory category, string key) =>
Assumptions.FirstOrDefault(a => a.Category == category &&
string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Returns the overall confidence level (minimum of all assumptions).
/// </summary>
public ConfidenceLevel OverallConfidence =>
Assumptions.Length == 0
? ConfidenceLevel.Low
: Assumptions.Min(a => a.Confidence);
/// <summary>
/// Returns the count of validated assumptions.
/// </summary>
public int ValidatedCount => Assumptions.Count(a => a.IsValidated);
/// <summary>
/// Returns the count of contradicted assumptions.
/// </summary>
public int ContradictedCount => Assumptions.Count(a => a.IsContradicted);
/// <summary>
/// Returns true if any assumption is contradicted by observed evidence.
/// </summary>
public bool HasContradictions => Assumptions.Any(a => a.IsContradicted);
/// <summary>
/// Returns the validation ratio (validated / total with observations).
/// </summary>
public double ValidationRatio
{
get
{
var withObservations = Assumptions.Count(a => a.ObservedValue is not null);
return withObservations == 0 ? 0.0 : (double)ValidatedCount / withObservations;
}
}
/// <summary>
/// Creates a new AssumptionSet with an additional assumption.
/// </summary>
public AssumptionSet WithAssumption(Assumption assumption) =>
this with { Assumptions = Assumptions.Add(assumption) };
/// <summary>
/// Creates a new AssumptionSet with updated observation for an assumption.
/// </summary>
public AssumptionSet WithObservation(AssumptionCategory category, string key, string observedValue)
{
var index = Assumptions.FindIndex(a =>
a.Category == category &&
string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase));
if (index < 0)
return this;
var updated = Assumptions[index] with { ObservedValue = observedValue };
return this with { Assumptions = Assumptions.SetItem(index, updated) };
}
}
/// <summary>
/// Extension methods for ImmutableArray to support FindIndex.
/// </summary>
internal static class ImmutableArrayExtensions
{
public static int FindIndex<T>(this ImmutableArray<T> array, Func<T, bool> predicate)
{
for (int i = 0; i < array.Length; i++)
{
if (predicate(array[i]))
return i;
}
return -1;
}
}

View File

@@ -0,0 +1,117 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
namespace StellaOps.Scanner.Explainability.Assumptions;
/// <summary>
/// Collects assumptions from various sources during vulnerability analysis.
/// </summary>
public interface IAssumptionCollector
{
/// <summary>
/// Records an assumption made during analysis.
/// </summary>
/// <param name="category">The category of assumption</param>
/// <param name="key">The assumption key</param>
/// <param name="assumedValue">The assumed value</param>
/// <param name="source">How the assumption was derived</param>
/// <param name="confidence">Confidence level</param>
void Record(
AssumptionCategory category,
string key,
string assumedValue,
AssumptionSource source,
ConfidenceLevel confidence = ConfidenceLevel.Low);
/// <summary>
/// Records an observation that validates or contradicts an assumption.
/// </summary>
/// <param name="category">The category of assumption</param>
/// <param name="key">The assumption key</param>
/// <param name="observedValue">The observed value</param>
void RecordObservation(AssumptionCategory category, string key, string observedValue);
/// <summary>
/// Builds the final assumption set from collected assumptions.
/// </summary>
/// <param name="contextId">Optional context identifier</param>
/// <returns>The completed assumption set</returns>
AssumptionSet Build(string? contextId = null);
/// <summary>
/// Clears all collected assumptions.
/// </summary>
void Clear();
}
/// <summary>
/// Default implementation of <see cref="IAssumptionCollector"/>.
/// </summary>
public sealed class AssumptionCollector : IAssumptionCollector
{
private readonly Dictionary<(AssumptionCategory, string), Assumption> _assumptions = new();
/// <inheritdoc />
public void Record(
AssumptionCategory category,
string key,
string assumedValue,
AssumptionSource source,
ConfidenceLevel confidence = ConfidenceLevel.Low)
{
var normalizedKey = key.ToLowerInvariant();
var existing = _assumptions.GetValueOrDefault((category, normalizedKey));
// Keep assumption with higher confidence
if (existing is null || confidence > existing.Confidence)
{
_assumptions[(category, normalizedKey)] = new Assumption(
category,
key,
assumedValue,
existing?.ObservedValue,
source,
confidence);
}
}
/// <inheritdoc />
public void RecordObservation(AssumptionCategory category, string key, string observedValue)
{
var normalizedKey = key.ToLowerInvariant();
if (_assumptions.TryGetValue((category, normalizedKey), out var existing))
{
_assumptions[(category, normalizedKey)] = existing with
{
ObservedValue = observedValue,
Confidence = ConfidenceLevel.Verified
};
}
else
{
// Record observation even without prior assumption
_assumptions[(category, normalizedKey)] = new Assumption(
category,
key,
observedValue, // Use observed as assumed when no prior assumption
observedValue,
AssumptionSource.RuntimeObservation,
ConfidenceLevel.Verified);
}
}
/// <inheritdoc />
public AssumptionSet Build(string? contextId = null)
{
return new AssumptionSet
{
Id = Guid.NewGuid().ToString("N"),
Assumptions = [.. _assumptions.Values],
CreatedAt = DateTimeOffset.UtcNow,
ContextId = contextId
};
}
/// <inheritdoc />
public void Clear() => _assumptions.Clear();
}

View File

@@ -0,0 +1,226 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Explainability.Falsifiability;
namespace StellaOps.Scanner.Explainability.Confidence;
/// <summary>
/// Evidence factors that contribute to confidence scoring.
/// </summary>
public sealed record EvidenceFactors
{
/// <summary>Assumption set for the finding</summary>
public AssumptionSet? Assumptions { get; init; }
/// <summary>Falsifiability criteria for the finding</summary>
public FalsifiabilityCriteria? Falsifiability { get; init; }
/// <summary>Whether static reachability analysis was performed</summary>
public bool HasStaticReachability { get; init; }
/// <summary>Whether runtime observations are available</summary>
public bool HasRuntimeObservations { get; init; }
/// <summary>Whether SBOM lineage is tracked</summary>
public bool HasSbomLineage { get; init; }
/// <summary>Number of corroborating vulnerability sources</summary>
public int SourceCount { get; init; } = 1;
/// <summary>Whether VEX assessment is available</summary>
public bool HasVexAssessment { get; init; }
/// <summary>Whether exploit code is known to exist</summary>
public bool HasKnownExploit { get; init; }
}
/// <summary>
/// Result of evidence density scoring.
/// </summary>
public sealed record EvidenceDensityScore
{
/// <summary>Overall confidence score (0.0 to 1.0)</summary>
public required double Score { get; init; }
/// <summary>Confidence level derived from score</summary>
public required ConfidenceLevel Level { get; init; }
/// <summary>Individual factor contributions</summary>
public required IReadOnlyDictionary<string, double> FactorBreakdown { get; init; }
/// <summary>Human-readable explanation</summary>
public required string Explanation { get; init; }
/// <summary>Recommendations to improve confidence</summary>
public required IReadOnlyList<string> ImprovementRecommendations { get; init; }
}
/// <summary>
/// Calculates confidence scores based on evidence density.
/// More evidence types and validation = higher confidence in the finding accuracy.
/// </summary>
public interface IEvidenceDensityScorer
{
/// <summary>
/// Calculates an evidence density score for a finding.
/// </summary>
EvidenceDensityScore Calculate(EvidenceFactors factors);
}
/// <summary>
/// Default implementation of <see cref="IEvidenceDensityScorer"/>.
/// </summary>
public sealed class EvidenceDensityScorer : IEvidenceDensityScorer
{
// Weights for different evidence factors
private const double WeightAssumptionValidation = 0.20;
private const double WeightFalsifiabilityEval = 0.15;
private const double WeightStaticReachability = 0.15;
private const double WeightRuntimeObservation = 0.20;
private const double WeightSbomLineage = 0.05;
private const double WeightMultipleSources = 0.10;
private const double WeightVexAssessment = 0.10;
private const double WeightKnownExploit = 0.05;
/// <inheritdoc />
public EvidenceDensityScore Calculate(EvidenceFactors factors)
{
var breakdown = new Dictionary<string, double>();
var recommendations = new List<string>();
// Factor 1: Assumption validation ratio
double assumptionScore = 0.0;
if (factors.Assumptions is not null && factors.Assumptions.Assumptions.Length > 0)
{
assumptionScore = factors.Assumptions.ValidationRatio * WeightAssumptionValidation;
if (factors.Assumptions.ValidationRatio < 0.5)
{
recommendations.Add("Validate more assumptions with runtime observations or static analysis");
}
}
else
{
recommendations.Add("Add assumption tracking to understand analysis context");
}
breakdown["assumption_validation"] = assumptionScore;
// Factor 2: Falsifiability evaluation
double falsifiabilityScore = 0.0;
if (factors.Falsifiability is not null)
{
var evaluatedCount = factors.Falsifiability.Criteria
.Count(c => c.Status is CriterionStatus.Satisfied or CriterionStatus.NotSatisfied);
var totalCount = factors.Falsifiability.Criteria.Length;
if (totalCount > 0)
{
falsifiabilityScore = ((double)evaluatedCount / totalCount) * WeightFalsifiabilityEval;
}
if (factors.Falsifiability.Status == FalsifiabilityStatus.PartiallyEvaluated)
{
recommendations.Add("Complete evaluation of pending falsifiability criteria");
}
}
else
{
recommendations.Add("Generate falsifiability criteria to understand what would disprove this finding");
}
breakdown["falsifiability_evaluation"] = falsifiabilityScore;
// Factor 3: Static reachability
double staticReachScore = factors.HasStaticReachability ? WeightStaticReachability : 0.0;
if (!factors.HasStaticReachability)
{
recommendations.Add("Perform static reachability analysis to verify code paths");
}
breakdown["static_reachability"] = staticReachScore;
// Factor 4: Runtime observations
double runtimeScore = factors.HasRuntimeObservations ? WeightRuntimeObservation : 0.0;
if (!factors.HasRuntimeObservations)
{
recommendations.Add("Collect runtime observations to verify actual behavior");
}
breakdown["runtime_observations"] = runtimeScore;
// Factor 5: SBOM lineage
double lineageScore = factors.HasSbomLineage ? WeightSbomLineage : 0.0;
if (!factors.HasSbomLineage)
{
recommendations.Add("Track SBOM lineage for reproducibility");
}
breakdown["sbom_lineage"] = lineageScore;
// Factor 6: Multiple sources
double sourceScore = Math.Min(factors.SourceCount, 3) / 3.0 * WeightMultipleSources;
if (factors.SourceCount < 2)
{
recommendations.Add("Cross-reference with additional vulnerability databases");
}
breakdown["multiple_sources"] = sourceScore;
// Factor 7: VEX assessment
double vexScore = factors.HasVexAssessment ? WeightVexAssessment : 0.0;
if (!factors.HasVexAssessment)
{
recommendations.Add("Obtain vendor VEX assessment for authoritative status");
}
breakdown["vex_assessment"] = vexScore;
// Factor 8: Known exploit
double exploitScore = factors.HasKnownExploit ? WeightKnownExploit : 0.0;
// Not having a known exploit is not a negative - don't recommend
breakdown["known_exploit"] = exploitScore;
// Calculate total score
double totalScore = breakdown.Values.Sum();
var level = ScoreToLevel(totalScore);
var explanation = GenerateExplanation(totalScore, level, breakdown);
return new EvidenceDensityScore
{
Score = Math.Round(totalScore, 3),
Level = level,
FactorBreakdown = breakdown,
Explanation = explanation,
ImprovementRecommendations = recommendations
};
}
private static ConfidenceLevel ScoreToLevel(double score) => score switch
{
>= 0.75 => ConfidenceLevel.Verified,
>= 0.50 => ConfidenceLevel.High,
>= 0.25 => ConfidenceLevel.Medium,
_ => ConfidenceLevel.Low
};
private static string GenerateExplanation(
double score,
ConfidenceLevel level,
Dictionary<string, double> breakdown)
{
var topFactors = breakdown
.Where(kv => kv.Value > 0)
.OrderByDescending(kv => kv.Value)
.Take(3)
.Select(kv => kv.Key.Replace("_", " "));
var factorList = string.Join(", ", topFactors);
return level switch
{
ConfidenceLevel.Verified =>
$"Very high confidence ({score:P0}). Strong evidence from: {factorList}.",
ConfidenceLevel.High =>
$"High confidence ({score:P0}). Good evidence from: {factorList}.",
ConfidenceLevel.Medium =>
$"Medium confidence ({score:P0}). Some evidence from: {factorList}.",
_ =>
$"Low confidence ({score:P0}). Limited evidence available. Consider gathering more data."
};
}
}

View File

@@ -0,0 +1,232 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Explainability.Confidence;
using StellaOps.Scanner.Explainability.Falsifiability;
namespace StellaOps.Scanner.Explainability.Dsse;
/// <summary>
/// Serializes explainability data to DSSE predicate format.
/// </summary>
public interface IExplainabilityPredicateSerializer
{
/// <summary>
/// The predicate type URI for finding explainability predicates.
/// </summary>
const string PredicateType = "https://stella-ops.org/predicates/finding-explainability/v2";
/// <summary>
/// Converts a risk report to DSSE predicate format.
/// </summary>
byte[] Serialize(RiskReport report);
/// <summary>
/// Converts a risk report to a predicate object that can be embedded in DSSE.
/// </summary>
FindingExplainabilityPredicate ToPredicate(RiskReport report);
}
/// <summary>
/// Default implementation of <see cref="IExplainabilityPredicateSerializer"/>.
/// </summary>
public sealed class ExplainabilityPredicateSerializer : IExplainabilityPredicateSerializer
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
/// <inheritdoc />
public byte[] Serialize(RiskReport report)
{
var predicate = ToPredicate(report);
return JsonSerializer.SerializeToUtf8Bytes(predicate, SerializerOptions);
}
/// <inheritdoc />
public FindingExplainabilityPredicate ToPredicate(RiskReport report)
{
return new FindingExplainabilityPredicate
{
FindingId = report.FindingId,
VulnerabilityId = report.VulnerabilityId,
PackageName = report.PackageName,
PackageVersion = report.PackageVersion,
GeneratedAt = report.GeneratedAt,
EngineVersion = report.EngineVersion,
Explanation = report.Explanation,
DetailedNarrative = report.DetailedNarrative,
Assumptions = report.Assumptions is not null ? ToPredicateAssumptions(report.Assumptions) : null,
Falsifiability = report.Falsifiability is not null ? ToPredicateFalsifiability(report.Falsifiability) : null,
ConfidenceScore = report.ConfidenceScore is not null ? ToPredicateConfidence(report.ConfidenceScore) : null,
RecommendedActions = report.RecommendedActions
.Select(a => new PredicateRecommendedAction
{
Priority = a.Priority,
Action = a.Action,
Rationale = a.Rationale,
Effort = a.Effort.ToString()
})
.ToArray()
};
}
private static PredicateAssumptionSet ToPredicateAssumptions(AssumptionSet assumptions)
{
return new PredicateAssumptionSet
{
Id = assumptions.Id,
ContextId = assumptions.ContextId,
CreatedAt = assumptions.CreatedAt,
Assumptions = assumptions.Assumptions
.Select(a => new PredicateAssumption
{
Category = a.Category.ToString(),
Key = a.Key,
AssumedValue = a.AssumedValue,
ObservedValue = a.ObservedValue,
Source = a.Source.ToString(),
Confidence = a.Confidence.ToString()
})
.ToArray()
};
}
private static PredicateFalsifiabilityCriteria ToPredicateFalsifiability(FalsifiabilityCriteria falsifiability)
{
return new PredicateFalsifiabilityCriteria
{
Id = falsifiability.Id,
FindingId = falsifiability.FindingId,
GeneratedAt = falsifiability.GeneratedAt,
Status = falsifiability.Status.ToString(),
Summary = falsifiability.Summary,
Criteria = falsifiability.Criteria
.Select(c => new PredicateFalsificationCriterion
{
Type = c.Type.ToString(),
Description = c.Description,
CheckExpression = c.CheckExpression,
Evidence = c.Evidence,
Status = c.Status.ToString()
})
.ToArray()
};
}
private static PredicateEvidenceDensityScore ToPredicateConfidence(EvidenceDensityScore score)
{
return new PredicateEvidenceDensityScore
{
Score = score.Score,
Level = score.Level.ToString(),
FactorBreakdown = score.FactorBreakdown.ToDictionary(kv => kv.Key, kv => kv.Value),
Explanation = score.Explanation,
ImprovementRecommendations = score.ImprovementRecommendations.ToArray()
};
}
}
#region Predicate DTOs
/// <summary>
/// DSSE predicate DTO for finding explainability.
/// </summary>
public sealed class FindingExplainabilityPredicate
{
public required string FindingId { get; init; }
public required string VulnerabilityId { get; init; }
public required string PackageName { get; init; }
public required string PackageVersion { get; init; }
public string? Severity { get; init; }
public string? FixedVersion { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public required string EngineVersion { get; init; }
public string? Explanation { get; init; }
public string? DetailedNarrative { get; init; }
public PredicateAssumptionSet? Assumptions { get; init; }
public PredicateFalsifiabilityCriteria? Falsifiability { get; init; }
public PredicateEvidenceDensityScore? ConfidenceScore { get; init; }
public PredicateRecommendedAction[]? RecommendedActions { get; init; }
}
/// <summary>
/// Predicate DTO for assumption set.
/// </summary>
public sealed class PredicateAssumptionSet
{
public required string Id { get; init; }
public string? ContextId { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required PredicateAssumption[] Assumptions { get; init; }
}
/// <summary>
/// Predicate DTO for individual assumption.
/// </summary>
public sealed class PredicateAssumption
{
public required string Category { get; init; }
public required string Key { get; init; }
public required string AssumedValue { get; init; }
public string? ObservedValue { get; init; }
public required string Source { get; init; }
public required string Confidence { get; init; }
}
/// <summary>
/// Predicate DTO for falsifiability criteria.
/// </summary>
public sealed class PredicateFalsifiabilityCriteria
{
public required string Id { get; init; }
public required string FindingId { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public required string Status { get; init; }
public string? Summary { get; init; }
public required PredicateFalsificationCriterion[] Criteria { get; init; }
}
/// <summary>
/// Predicate DTO for individual falsification criterion.
/// </summary>
public sealed class PredicateFalsificationCriterion
{
public required string Type { get; init; }
public required string Description { get; init; }
public string? CheckExpression { get; init; }
public string? Evidence { get; init; }
public required string Status { get; init; }
}
/// <summary>
/// Predicate DTO for evidence density score.
/// </summary>
public sealed class PredicateEvidenceDensityScore
{
public required double Score { get; init; }
public required string Level { get; init; }
public Dictionary<string, double>? FactorBreakdown { get; init; }
public string? Explanation { get; init; }
public string[]? ImprovementRecommendations { get; init; }
}
/// <summary>
/// Predicate DTO for recommended action.
/// </summary>
public sealed class PredicateRecommendedAction
{
public required int Priority { get; init; }
public required string Action { get; init; }
public required string Rationale { get; init; }
public required string Effort { get; init; }
}
#endregion

View File

@@ -0,0 +1,131 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using System.Collections.Immutable;
namespace StellaOps.Scanner.Explainability.Falsifiability;
/// <summary>
/// Represents criteria that would falsify (disprove) a vulnerability finding.
/// Answers the question: "What would prove this finding wrong?"
/// </summary>
public sealed record FalsifiabilityCriteria
{
/// <summary>
/// Unique identifier for this criteria set.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// The finding ID these criteria apply to.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Individual criteria that would disprove the finding.
/// </summary>
public ImmutableArray<FalsificationCriterion> Criteria { get; init; } = [];
/// <summary>
/// Overall falsifiability status.
/// </summary>
public FalsifiabilityStatus Status { get; init; } = FalsifiabilityStatus.Unknown;
/// <summary>
/// Human-readable summary of what would disprove this finding.
/// </summary>
public string? Summary { get; init; }
/// <summary>
/// When these criteria were generated.
/// </summary>
public required DateTimeOffset GeneratedAt { get; init; }
}
/// <summary>
/// A single criterion that would falsify a finding.
/// </summary>
/// <param name="Type">The type of falsification check</param>
/// <param name="Description">Human-readable description of the criterion</param>
/// <param name="CheckExpression">Machine-evaluable expression (e.g., CEL, Rego)</param>
/// <param name="Evidence">Evidence that supports or refutes this criterion</param>
/// <param name="Status">Current evaluation status</param>
public sealed record FalsificationCriterion(
FalsificationType Type,
string Description,
string? CheckExpression,
string? Evidence,
CriterionStatus Status
);
/// <summary>
/// Types of falsification criteria.
/// </summary>
public enum FalsificationType
{
/// <summary>Package is not actually installed</summary>
PackageNotPresent,
/// <summary>Vulnerable version is not the installed version</summary>
VersionMismatch,
/// <summary>Vulnerable code path is not reachable</summary>
CodeUnreachable,
/// <summary>Required feature/function is disabled</summary>
FeatureDisabled,
/// <summary>Mitigation is in place (ASLR, stack canaries, etc.)</summary>
MitigationPresent,
/// <summary>Network exposure required but not present</summary>
NoNetworkExposure,
/// <summary>Required privileges not available</summary>
InsufficientPrivileges,
/// <summary>Vulnerability is already patched</summary>
PatchApplied,
/// <summary>Configuration prevents exploitation</summary>
ConfigurationPrevents,
/// <summary>Runtime environment prevents exploitation</summary>
RuntimePrevents
}
/// <summary>
/// Status of a falsification criterion evaluation.
/// </summary>
public enum CriterionStatus
{
/// <summary>Not yet evaluated</summary>
Pending,
/// <summary>Criterion is satisfied (finding is falsified)</summary>
Satisfied,
/// <summary>Criterion is not satisfied (finding stands)</summary>
NotSatisfied,
/// <summary>Could not be evaluated (insufficient data)</summary>
Inconclusive
}
/// <summary>
/// Overall falsifiability status.
/// </summary>
public enum FalsifiabilityStatus
{
/// <summary>Status not determined</summary>
Unknown,
/// <summary>Finding has been falsified (at least one criterion satisfied)</summary>
Falsified,
/// <summary>Finding stands (all criteria not satisfied)</summary>
NotFalsified,
/// <summary>Some criteria inconclusive</summary>
PartiallyEvaluated
}

View File

@@ -0,0 +1,215 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Explainability.Assumptions;
namespace StellaOps.Scanner.Explainability.Falsifiability;
/// <summary>
/// Input data for generating falsifiability criteria.
/// </summary>
public sealed record FalsifiabilityInput
{
/// <summary>The finding ID</summary>
public required string FindingId { get; init; }
/// <summary>The CVE or vulnerability ID</summary>
public required string VulnerabilityId { get; init; }
/// <summary>Package name</summary>
public required string PackageName { get; init; }
/// <summary>Installed version</summary>
public required string InstalledVersion { get; init; }
/// <summary>Vulnerable version range</summary>
public string? VulnerableRange { get; init; }
/// <summary>Fixed version, if available</summary>
public string? FixedVersion { get; init; }
/// <summary>Assumptions made during analysis</summary>
public AssumptionSet? Assumptions { get; init; }
/// <summary>Whether reachability analysis was performed</summary>
public bool HasReachabilityData { get; init; }
/// <summary>Whether code is reachable (if analysis was performed)</summary>
public bool? IsReachable { get; init; }
/// <summary>Known mitigations in place</summary>
public ImmutableArray<string> Mitigations { get; init; } = [];
}
/// <summary>
/// Generates falsifiability criteria for vulnerability findings.
/// </summary>
public interface IFalsifiabilityGenerator
{
/// <summary>
/// Generates falsifiability criteria for a finding.
/// </summary>
FalsifiabilityCriteria Generate(FalsifiabilityInput input);
}
/// <summary>
/// Default implementation of <see cref="IFalsifiabilityGenerator"/>.
/// </summary>
public sealed class FalsifiabilityGenerator : IFalsifiabilityGenerator
{
private readonly ILogger<FalsifiabilityGenerator> _logger;
public FalsifiabilityGenerator(ILogger<FalsifiabilityGenerator> logger)
{
_logger = logger;
}
/// <inheritdoc />
public FalsifiabilityCriteria Generate(FalsifiabilityInput input)
{
_logger.LogDebug("Generating falsifiability criteria for finding {FindingId}", input.FindingId);
var criteria = new List<FalsificationCriterion>();
// Criterion 1: Package presence
criteria.Add(new FalsificationCriterion(
FalsificationType.PackageNotPresent,
$"Package '{input.PackageName}' is not actually installed or is a false positive from manifest parsing",
$"package.exists(\"{input.PackageName}\") == false",
null,
CriterionStatus.Pending));
// Criterion 2: Version mismatch
if (input.VulnerableRange is not null)
{
criteria.Add(new FalsificationCriterion(
FalsificationType.VersionMismatch,
$"Installed version '{input.InstalledVersion}' is not within vulnerable range '{input.VulnerableRange}'",
$"version.inRange(\"{input.InstalledVersion}\", \"{input.VulnerableRange}\") == false",
null,
CriterionStatus.Pending));
}
// Criterion 3: Patch applied
if (input.FixedVersion is not null)
{
criteria.Add(new FalsificationCriterion(
FalsificationType.PatchApplied,
$"Version '{input.InstalledVersion}' is at or above fixed version '{input.FixedVersion}'",
$"version.gte(\"{input.InstalledVersion}\", \"{input.FixedVersion}\")",
null,
CriterionStatus.Pending));
}
// Criterion 4: Code unreachable
if (input.HasReachabilityData)
{
var reachabilityStatus = input.IsReachable switch
{
false => CriterionStatus.Satisfied,
true => CriterionStatus.NotSatisfied,
null => CriterionStatus.Inconclusive
};
criteria.Add(new FalsificationCriterion(
FalsificationType.CodeUnreachable,
"Vulnerable code path is not reachable from application entry points",
"reachability.isReachable() == false",
input.IsReachable.HasValue ? $"Reachability analysis: {(input.IsReachable.Value ? "reachable" : "unreachable")}" : null,
reachabilityStatus));
}
// Criterion 5: Mitigations
foreach (var mitigation in input.Mitigations)
{
criteria.Add(new FalsificationCriterion(
FalsificationType.MitigationPresent,
$"Mitigation '{mitigation}' prevents exploitation",
null,
$"Mitigation present: {mitigation}",
CriterionStatus.Satisfied));
}
// Criterion 6: Assumption-based criteria
if (input.Assumptions is not null)
{
foreach (var assumption in input.Assumptions.Assumptions.Where(a => a.IsContradicted))
{
var type = assumption.Category switch
{
AssumptionCategory.NetworkExposure => FalsificationType.NoNetworkExposure,
AssumptionCategory.ProcessPrivilege => FalsificationType.InsufficientPrivileges,
AssumptionCategory.FeatureGate => FalsificationType.FeatureDisabled,
AssumptionCategory.RuntimeConfig => FalsificationType.ConfigurationPrevents,
AssumptionCategory.CompilerFlag => FalsificationType.MitigationPresent,
_ => FalsificationType.RuntimePrevents
};
criteria.Add(new FalsificationCriterion(
type,
$"Assumption '{assumption.Key}' was contradicted: assumed '{assumption.AssumedValue}', observed '{assumption.ObservedValue}'",
null,
$"Observed: {assumption.ObservedValue}",
CriterionStatus.Satisfied));
}
}
// Determine overall status
var status = DetermineOverallStatus(criteria);
// Generate summary
var summary = GenerateSummary(input, criteria, status);
return new FalsifiabilityCriteria
{
Id = Guid.NewGuid().ToString("N"),
FindingId = input.FindingId,
Criteria = [.. criteria],
Status = status,
Summary = summary,
GeneratedAt = DateTimeOffset.UtcNow
};
}
private static FalsifiabilityStatus DetermineOverallStatus(List<FalsificationCriterion> criteria)
{
if (criteria.Count == 0)
return FalsifiabilityStatus.Unknown;
if (criteria.Any(c => c.Status == CriterionStatus.Satisfied))
return FalsifiabilityStatus.Falsified;
if (criteria.All(c => c.Status == CriterionStatus.NotSatisfied))
return FalsifiabilityStatus.NotFalsified;
if (criteria.Any(c => c.Status is CriterionStatus.Pending or CriterionStatus.Inconclusive))
return FalsifiabilityStatus.PartiallyEvaluated;
return FalsifiabilityStatus.Unknown;
}
private static string GenerateSummary(
FalsifiabilityInput input,
List<FalsificationCriterion> criteria,
FalsifiabilityStatus status)
{
return status switch
{
FalsifiabilityStatus.Falsified =>
$"Finding {input.FindingId} can be falsified. " +
$"Criteria satisfied: {string.Join(", ", criteria.Where(c => c.Status == CriterionStatus.Satisfied).Select(c => c.Type))}",
FalsifiabilityStatus.NotFalsified =>
$"Finding {input.FindingId} has not been falsified. All {criteria.Count} criteria evaluated negative.",
FalsifiabilityStatus.PartiallyEvaluated =>
$"Finding {input.FindingId} is partially evaluated. " +
$"{criteria.Count(c => c.Status == CriterionStatus.Pending)} pending, " +
$"{criteria.Count(c => c.Status == CriterionStatus.Inconclusive)} inconclusive.",
_ => $"Finding {input.FindingId} falsifiability status unknown."
};
}
}

View File

@@ -0,0 +1,269 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using System.Collections.Immutable;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Explainability.Confidence;
using StellaOps.Scanner.Explainability.Falsifiability;
namespace StellaOps.Scanner.Explainability;
/// <summary>
/// A comprehensive risk report that includes all explainability data for a finding.
/// </summary>
public sealed record RiskReport
{
/// <summary>Unique report identifier</summary>
public required string Id { get; init; }
/// <summary>The finding this report explains</summary>
public required string FindingId { get; init; }
/// <summary>The vulnerability ID (CVE, GHSA, etc.)</summary>
public required string VulnerabilityId { get; init; }
/// <summary>Package name</summary>
public required string PackageName { get; init; }
/// <summary>Package version</summary>
public required string PackageVersion { get; init; }
/// <summary>Assumptions made during analysis</summary>
public AssumptionSet? Assumptions { get; init; }
/// <summary>Falsifiability criteria and status</summary>
public FalsifiabilityCriteria? Falsifiability { get; init; }
/// <summary>Evidence density confidence score</summary>
public EvidenceDensityScore? ConfidenceScore { get; init; }
/// <summary>Human-readable explanation of the finding</summary>
public required string Explanation { get; init; }
/// <summary>Detailed narrative explaining the risk</summary>
public string? DetailedNarrative { get; init; }
/// <summary>Recommended actions</summary>
public ImmutableArray<RecommendedAction> RecommendedActions { get; init; } = [];
/// <summary>When this report was generated</summary>
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>Version of the explainability engine</summary>
public required string EngineVersion { get; init; }
}
/// <summary>
/// A recommended action to address a finding.
/// </summary>
/// <param name="Priority">Action priority (1 = highest)</param>
/// <param name="Action">The recommended action</param>
/// <param name="Rationale">Why this action is recommended</param>
/// <param name="Effort">Estimated effort level</param>
public sealed record RecommendedAction(
int Priority,
string Action,
string Rationale,
EffortLevel Effort
);
/// <summary>
/// Effort level for a recommended action.
/// </summary>
public enum EffortLevel
{
/// <summary>Quick configuration change or update</summary>
Low,
/// <summary>Moderate code changes or testing required</summary>
Medium,
/// <summary>Significant refactoring or architectural changes</summary>
High
}
/// <summary>
/// Generates comprehensive risk reports.
/// </summary>
public interface IRiskReportGenerator
{
/// <summary>
/// Generates a risk report for a finding.
/// </summary>
RiskReport Generate(RiskReportInput input);
}
/// <summary>
/// Input for generating a risk report.
/// </summary>
public sealed record RiskReportInput
{
public required string FindingId { get; init; }
public required string VulnerabilityId { get; init; }
public required string PackageName { get; init; }
public required string PackageVersion { get; init; }
public string? Severity { get; init; }
public string? Description { get; init; }
public string? FixedVersion { get; init; }
public AssumptionSet? Assumptions { get; init; }
public FalsifiabilityCriteria? Falsifiability { get; init; }
public EvidenceFactors? EvidenceFactors { get; init; }
}
/// <summary>
/// Default implementation of <see cref="IRiskReportGenerator"/>.
/// </summary>
public sealed class RiskReportGenerator : IRiskReportGenerator
{
private const string EngineVersionValue = "1.0.0";
private readonly IEvidenceDensityScorer _scorer;
public RiskReportGenerator(IEvidenceDensityScorer scorer)
{
_scorer = scorer;
}
/// <inheritdoc />
public RiskReport Generate(RiskReportInput input)
{
// Calculate confidence score if evidence factors provided
EvidenceDensityScore? confidenceScore = null;
if (input.EvidenceFactors is not null)
{
confidenceScore = _scorer.Calculate(input.EvidenceFactors);
}
var explanation = GenerateExplanation(input);
var narrative = GenerateNarrative(input, confidenceScore);
var actions = GenerateRecommendedActions(input);
return new RiskReport
{
Id = Guid.NewGuid().ToString("N"),
FindingId = input.FindingId,
VulnerabilityId = input.VulnerabilityId,
PackageName = input.PackageName,
PackageVersion = input.PackageVersion,
Assumptions = input.Assumptions,
Falsifiability = input.Falsifiability,
ConfidenceScore = confidenceScore,
Explanation = explanation,
DetailedNarrative = narrative,
RecommendedActions = [.. actions],
GeneratedAt = DateTimeOffset.UtcNow,
EngineVersion = EngineVersionValue
};
}
private static string GenerateExplanation(RiskReportInput input)
{
var parts = new List<string>
{
$"Vulnerability {input.VulnerabilityId} affects {input.PackageName}@{input.PackageVersion}."
};
if (input.Severity is not null)
{
parts.Add($"Severity: {input.Severity}.");
}
if (input.Falsifiability?.Status == FalsifiabilityStatus.Falsified)
{
parts.Add("This finding has been falsified and may not be exploitable in your environment.");
}
else if (input.Assumptions?.HasContradictions == true)
{
parts.Add("Some analysis assumptions have been contradicted by observed evidence.");
}
return string.Join(" ", parts);
}
private static string GenerateNarrative(RiskReportInput input, EvidenceDensityScore? score)
{
var sections = new List<string>();
// Overview
sections.Add($"## Overview\n{input.Description ?? "No description available."}");
// Assumptions section
if (input.Assumptions is not null && input.Assumptions.Assumptions.Length > 0)
{
var assumptionLines = input.Assumptions.Assumptions
.Select(a => $"- **{a.Category}**: {a.Key} = {a.AssumedValue}" +
(a.ObservedValue is not null ? $" (observed: {a.ObservedValue})" : ""));
sections.Add($"## Assumptions\n{string.Join("\n", assumptionLines)}");
}
// Falsifiability section
if (input.Falsifiability is not null)
{
sections.Add($"## Falsifiability\n**Status**: {input.Falsifiability.Status}\n\n{input.Falsifiability.Summary}");
}
// Confidence section
if (score is not null)
{
sections.Add($"## Confidence Assessment\n{score.Explanation}");
if (score.ImprovementRecommendations.Count > 0)
{
var recs = score.ImprovementRecommendations.Select(r => $"- {r}");
sections.Add($"### Recommendations to Improve Confidence\n{string.Join("\n", recs)}");
}
}
return string.Join("\n\n", sections);
}
private static List<RecommendedAction> GenerateRecommendedActions(RiskReportInput input)
{
var actions = new List<RecommendedAction>();
int priority = 1;
// Action: Update package if fix available
if (input.FixedVersion is not null)
{
actions.Add(new RecommendedAction(
priority++,
$"Update {input.PackageName} to version {input.FixedVersion} or later",
"A fixed version is available that addresses this vulnerability",
EffortLevel.Low));
}
// Action: Validate assumptions
if (input.Assumptions is not null && input.Assumptions.ValidatedCount < input.Assumptions.Assumptions.Length)
{
actions.Add(new RecommendedAction(
priority++,
"Validate analysis assumptions with runtime observations",
$"Only {input.Assumptions.ValidatedCount}/{input.Assumptions.Assumptions.Length} assumptions are validated",
EffortLevel.Medium));
}
// Action: Evaluate falsifiability criteria
if (input.Falsifiability?.Status == FalsifiabilityStatus.PartiallyEvaluated)
{
var pendingCount = input.Falsifiability.Criteria.Count(c => c.Status == CriterionStatus.Pending);
actions.Add(new RecommendedAction(
priority++,
"Complete falsifiability evaluation",
$"{pendingCount} criteria are pending evaluation",
EffortLevel.Medium));
}
// Default action if no fix available
if (input.FixedVersion is null)
{
actions.Add(new RecommendedAction(
priority,
"Monitor for vendor patch or implement compensating controls",
"No fixed version is currently available",
EffortLevel.High));
}
return actions;
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
</ItemGroup>
</Project>