notify doctors work, audit work, new product advisory sprints
This commit is contained in:
@@ -426,8 +426,13 @@ function flush() {
|
||||
const sorted = events.sort((a, b) => {
|
||||
const at = String(a.ts);
|
||||
const bt = String(b.ts);
|
||||
if (at === bt) return String(a.type).localeCompare(String(b.type));
|
||||
return at.localeCompare(bt);
|
||||
if (at === bt) {
|
||||
const atype = String(a.type);
|
||||
const btype = String(b.type);
|
||||
if (atype === btype) return 0;
|
||||
return atype < btype ? -1 : 1;
|
||||
}
|
||||
return at < bt ? -1 : 1;
|
||||
});
|
||||
|
||||
const data = sorted.map((e) => JSON.stringify(e)).join("\\n");
|
||||
|
||||
@@ -11,11 +11,12 @@ internal sealed class DenoRuntimeTraceRecorder
|
||||
private readonly string _rootPath;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DenoRuntimeTraceRecorder(string rootPath, TimeProvider? timeProvider = null)
|
||||
public DenoRuntimeTraceRecorder(string rootPath, TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
_rootPath = Path.GetFullPath(rootPath);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public void AddModuleLoad(string absoluteModulePath, string reason, IEnumerable<string> permissions, string? origin = null, DateTimeOffset? timestamp = null)
|
||||
|
||||
@@ -13,6 +13,12 @@ internal static class DenoRuntimeTraceRunner
|
||||
private const string EntrypointEnvVar = "STELLA_DENO_ENTRYPOINT";
|
||||
private const string BinaryEnvVar = "STELLA_DENO_BINARY";
|
||||
private const string RuntimeFileName = "deno-runtime.ndjson";
|
||||
private static readonly HashSet<string> AllowedBinaryNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"deno",
|
||||
"deno.exe",
|
||||
"deno.cmd"
|
||||
};
|
||||
|
||||
public static async Task<bool> TryExecuteAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
@@ -28,29 +34,28 @@ internal static class DenoRuntimeTraceRunner
|
||||
return false;
|
||||
}
|
||||
|
||||
var entrypointPath = Path.GetFullPath(Path.Combine(context.RootPath, entrypoint));
|
||||
if (!File.Exists(entrypointPath))
|
||||
var rootPath = Path.GetFullPath(context.RootPath);
|
||||
if (!TryResolveEntrypointPath(rootPath, entrypoint, logger, out var entrypointPath))
|
||||
{
|
||||
logger?.LogWarning("Deno runtime trace skipped: entrypoint '{Entrypoint}' missing", entrypointPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
var shimPath = Path.Combine(context.RootPath, DenoRuntimeShim.FileName);
|
||||
if (!File.Exists(shimPath))
|
||||
{
|
||||
await DenoRuntimeShim.WriteAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var binary = Environment.GetEnvironmentVariable(BinaryEnvVar);
|
||||
var binary = ResolveBinary(rootPath, Environment.GetEnvironmentVariable(BinaryEnvVar), logger);
|
||||
if (string.IsNullOrWhiteSpace(binary))
|
||||
{
|
||||
binary = "deno";
|
||||
return false;
|
||||
}
|
||||
|
||||
var shimPath = Path.Combine(rootPath, DenoRuntimeShim.FileName);
|
||||
if (!File.Exists(shimPath))
|
||||
{
|
||||
await DenoRuntimeShim.WriteAsync(rootPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = binary,
|
||||
WorkingDirectory = context.RootPath,
|
||||
WorkingDirectory = rootPath,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
@@ -58,7 +63,7 @@ internal static class DenoRuntimeTraceRunner
|
||||
|
||||
startInfo.ArgumentList.Add("run");
|
||||
startInfo.ArgumentList.Add("--cached-only");
|
||||
startInfo.ArgumentList.Add("--allow-read");
|
||||
startInfo.ArgumentList.Add(BuildAllowReadArgument(rootPath, logger));
|
||||
startInfo.ArgumentList.Add("--allow-env");
|
||||
startInfo.ArgumentList.Add("--quiet");
|
||||
startInfo.ArgumentList.Add(shimPath);
|
||||
@@ -96,7 +101,7 @@ internal static class DenoRuntimeTraceRunner
|
||||
return false;
|
||||
}
|
||||
|
||||
var runtimePath = Path.Combine(context.RootPath, RuntimeFileName);
|
||||
var runtimePath = Path.Combine(rootPath, RuntimeFileName);
|
||||
if (!File.Exists(runtimePath))
|
||||
{
|
||||
logger?.LogWarning(
|
||||
@@ -108,6 +113,122 @@ internal static class DenoRuntimeTraceRunner
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryResolveEntrypointPath(
|
||||
string rootPath,
|
||||
string entrypoint,
|
||||
ILogger? logger,
|
||||
out string entrypointPath)
|
||||
{
|
||||
entrypointPath = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(entrypoint))
|
||||
{
|
||||
logger?.LogWarning("Deno runtime trace skipped: entrypoint was empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var candidate = Path.GetFullPath(Path.Combine(rootPath, entrypoint));
|
||||
if (!IsWithinRoot(rootPath, candidate))
|
||||
{
|
||||
logger?.LogWarning("Deno runtime trace skipped: entrypoint '{Entrypoint}' not under root", entrypoint);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(candidate))
|
||||
{
|
||||
logger?.LogWarning("Deno runtime trace skipped: entrypoint '{Entrypoint}' missing", candidate);
|
||||
return false;
|
||||
}
|
||||
|
||||
entrypointPath = candidate;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or PathTooLongException or UnauthorizedAccessException)
|
||||
{
|
||||
logger?.LogWarning(ex, "Deno runtime trace skipped: entrypoint '{Entrypoint}' invalid", entrypoint);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveBinary(string rootPath, string? binary, ILogger? logger)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(binary))
|
||||
{
|
||||
return "deno";
|
||||
}
|
||||
|
||||
var trimmed = binary.Trim();
|
||||
var fileName = Path.GetFileName(trimmed);
|
||||
if (string.IsNullOrWhiteSpace(fileName) || !AllowedBinaryNames.Contains(fileName))
|
||||
{
|
||||
logger?.LogWarning("Deno runtime trace skipped: binary '{Binary}' not allowlisted", trimmed);
|
||||
return null;
|
||||
}
|
||||
|
||||
var isPath = trimmed.Contains(Path.DirectorySeparatorChar) ||
|
||||
trimmed.Contains(Path.AltDirectorySeparatorChar) ||
|
||||
Path.IsPathRooted(trimmed);
|
||||
if (!isPath)
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var candidate = Path.GetFullPath(Path.IsPathRooted(trimmed)
|
||||
? trimmed
|
||||
: Path.Combine(rootPath, trimmed));
|
||||
if (!IsWithinRoot(rootPath, candidate))
|
||||
{
|
||||
logger?.LogWarning("Deno runtime trace skipped: binary '{Binary}' not under root", trimmed);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!File.Exists(candidate))
|
||||
{
|
||||
logger?.LogWarning("Deno runtime trace skipped: binary '{Binary}' missing", candidate);
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or PathTooLongException or UnauthorizedAccessException)
|
||||
{
|
||||
logger?.LogWarning(ex, "Deno runtime trace skipped: binary '{Binary}' invalid", trimmed);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildAllowReadArgument(string rootPath, ILogger? logger)
|
||||
{
|
||||
var comparison = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
|
||||
var allowed = new HashSet<string>(comparison) { rootPath };
|
||||
var denoDir = Environment.GetEnvironmentVariable("DENO_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(denoDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
var denoDirPath = Path.GetFullPath(denoDir, rootPath);
|
||||
allowed.Add(denoDirPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or PathTooLongException or UnauthorizedAccessException)
|
||||
{
|
||||
logger?.LogWarning(ex, "Deno runtime trace: invalid DENO_DIR '{DenoDir}'", denoDir);
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = allowed.OrderBy(path => path, comparison);
|
||||
return $"--allow-read={string.Join(",", ordered)}";
|
||||
}
|
||||
|
||||
private static bool IsWithinRoot(string rootPath, string candidatePath)
|
||||
{
|
||||
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
var normalizedRoot = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return candidatePath.StartsWith(normalizedRoot, comparison);
|
||||
}
|
||||
|
||||
private static string Truncate(string? value, int maxLength = 400)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Runtime;
|
||||
@@ -8,7 +9,7 @@ internal static class DenoRuntimeTraceSerializer
|
||||
{
|
||||
private static readonly JsonWriterOptions WriterOptions = new()
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Encoder = JavaScriptEncoder.Default,
|
||||
Indented = false
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Bundling;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
@@ -278,7 +279,7 @@ internal sealed record BundlingSignal(
|
||||
yield return new("bundle.detected", "true");
|
||||
yield return new("bundle.filePath", FilePath);
|
||||
yield return new("bundle.kind", Kind.ToString().ToLowerInvariant());
|
||||
yield return new("bundle.sizeBytes", SizeBytes.ToString());
|
||||
yield return new("bundle.sizeBytes", SizeBytes.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
if (IsSkipped)
|
||||
{
|
||||
@@ -292,7 +293,7 @@ internal sealed record BundlingSignal(
|
||||
{
|
||||
if (EstimatedBundledAssemblies > 0)
|
||||
{
|
||||
yield return new("bundle.estimatedAssemblies", EstimatedBundledAssemblies.ToString());
|
||||
yield return new("bundle.estimatedAssemblies", EstimatedBundledAssemblies.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
for (var i = 0; i < Indicators.Length; i++)
|
||||
|
||||
@@ -23,10 +23,10 @@ internal sealed class DotNetCallgraphBuilder
|
||||
private int _assemblyCount;
|
||||
private int _typeCount;
|
||||
|
||||
public DotNetCallgraphBuilder(string contextDigest, TimeProvider? timeProvider = null)
|
||||
public DotNetCallgraphBuilder(string contextDigest, TimeProvider timeProvider)
|
||||
{
|
||||
_contextDigest = contextDigest;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -232,6 +232,7 @@ internal static class DotNetLicenseCache
|
||||
DtdProcessing = DtdProcessing.Ignore,
|
||||
IgnoreComments = true,
|
||||
IgnoreWhitespace = true,
|
||||
XmlResolver = null,
|
||||
});
|
||||
|
||||
var expressions = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Scanner .NET Analyzer Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_apply.md` and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-HOTLIST-SCANNER-LANG-DOTNET-0001 | DONE | Applied hotlist fixes and tests. |
|
||||
| AUDIT-0644-A | DONE | Audit tracker updated for DotNet analyzer apply. |
|
||||
| AUDIT-0698-A | DONE | Test project apply completed (warnings, deterministic fixtures). |
|
||||
@@ -18,10 +18,11 @@ internal sealed class NativeCallgraphBuilder
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private int _binaryCount;
|
||||
|
||||
public NativeCallgraphBuilder(string layerDigest, TimeProvider? timeProvider = null)
|
||||
public NativeCallgraphBuilder(string layerDigest, TimeProvider timeProvider)
|
||||
{
|
||||
_layerDigest = layerDigest;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -16,6 +16,14 @@ namespace StellaOps.Scanner.Analyzers.Native;
|
||||
/// </summary>
|
||||
public sealed class NativeReachabilityAnalyzer
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public NativeReachabilityAnalyzer(TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes a directory of ELF binaries and produces a reachability graph.
|
||||
/// </summary>
|
||||
@@ -31,7 +39,7 @@ public sealed class NativeReachabilityAnalyzer
|
||||
ArgumentException.ThrowIfNullOrEmpty(layerPath);
|
||||
ArgumentException.ThrowIfNullOrEmpty(layerDigest);
|
||||
|
||||
var builder = new NativeCallgraphBuilder(layerDigest);
|
||||
var builder = new NativeCallgraphBuilder(layerDigest, _timeProvider);
|
||||
|
||||
// Find all potential ELF files in the layer
|
||||
await foreach (var filePath in FindElfFilesAsync(layerPath, cancellationToken))
|
||||
@@ -73,7 +81,7 @@ public sealed class NativeReachabilityAnalyzer
|
||||
ArgumentException.ThrowIfNullOrEmpty(filePath);
|
||||
ArgumentException.ThrowIfNullOrEmpty(layerDigest);
|
||||
|
||||
var builder = new NativeCallgraphBuilder(layerDigest);
|
||||
var builder = new NativeCallgraphBuilder(layerDigest, _timeProvider);
|
||||
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var elf = ElfReader.Parse(stream, filePath, layerDigest);
|
||||
@@ -98,7 +106,7 @@ public sealed class NativeReachabilityAnalyzer
|
||||
ArgumentException.ThrowIfNullOrEmpty(filePath);
|
||||
ArgumentException.ThrowIfNullOrEmpty(layerDigest);
|
||||
|
||||
var builder = new NativeCallgraphBuilder(layerDigest);
|
||||
var builder = new NativeCallgraphBuilder(layerDigest, _timeProvider);
|
||||
var elf = ElfReader.Parse(stream, filePath, layerDigest);
|
||||
|
||||
if (elf is not null)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture.Timeline;
|
||||
|
||||
public interface ITimelineBuilder
|
||||
@@ -108,7 +110,7 @@ public sealed class TimelineBuilder : ITimelineBuilder
|
||||
Details = new Dictionary<string, string>
|
||||
{
|
||||
["path"] = obs.Path ?? "",
|
||||
["process_id"] = obs.ProcessId.ToString()
|
||||
["process_id"] = obs.ProcessId.ToString(CultureInfo.InvariantCulture)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ public static class CallGraphDigests
|
||||
{
|
||||
private static readonly JsonWriterOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.Default,
|
||||
Indented = false,
|
||||
SkipValidation = false
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Scanner Contracts Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_apply.md` and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-HOTLIST-SCANNER-CONTRACTS-0001 | DONE | Applied safe JSON encoder and test coverage update. |
|
||||
| AUDIT-0946-A | DONE | Audit tracker updated for Scanner.Contracts apply. |
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -414,23 +415,23 @@ public sealed class PrReachabilityGate : IPrReachabilityGate
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine(passed
|
||||
? "## ✅ Reachability Gate Passed"
|
||||
: "## ❌ Reachability Gate Blocked");
|
||||
? "## [OK] Reachability Gate Passed"
|
||||
: "## [BLOCKED] Reachability Gate Blocked");
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Metric | Value |");
|
||||
sb.AppendLine("|--------|-------|");
|
||||
sb.AppendLine($"| New reachable paths | {decision.NewReachableCount} |");
|
||||
sb.AppendLine($"| New reachable paths | {decision.NewReachableCount.ToString(CultureInfo.InvariantCulture)} |");
|
||||
|
||||
if (options.IncludeMitigatedInSummary)
|
||||
{
|
||||
sb.AppendLine($"| Mitigated paths | {decision.MitigatedCount} |");
|
||||
sb.AppendLine($"| Net change | {decision.NetChange:+#;-#;0} |");
|
||||
sb.AppendLine($"| Mitigated paths | {decision.MitigatedCount.ToString(CultureInfo.InvariantCulture)} |");
|
||||
sb.AppendLine($"| Net change | {decision.NetChange.ToString("+#;-#;0", CultureInfo.InvariantCulture)} |");
|
||||
}
|
||||
|
||||
sb.AppendLine($"| Analysis type | {(decision.WasIncremental ? "Incremental" : "Full")} |");
|
||||
sb.AppendLine($"| Cache savings | {decision.SavingsRatio:P0} |");
|
||||
sb.AppendLine($"| Duration | {decision.Duration.TotalMilliseconds:F0}ms |");
|
||||
sb.AppendLine($"| Cache savings | {decision.SavingsRatio.ToString("P0", CultureInfo.InvariantCulture)} |");
|
||||
sb.AppendLine($"| Duration | {decision.Duration.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture)}ms |");
|
||||
|
||||
if (!passed && decision.BlockingFlips.Count > 0)
|
||||
{
|
||||
@@ -440,12 +441,13 @@ public sealed class PrReachabilityGate : IPrReachabilityGate
|
||||
|
||||
foreach (var flip in decision.BlockingFlips.Take(10))
|
||||
{
|
||||
sb.AppendLine($"- `{flip.EntryMethodKey}` -> `{flip.SinkMethodKey}` (confidence: {flip.Confidence:P0})");
|
||||
sb.AppendLine($"- `{flip.EntryMethodKey}` -> `{flip.SinkMethodKey}` (confidence: {flip.Confidence.ToString("P0", CultureInfo.InvariantCulture)})");
|
||||
}
|
||||
|
||||
if (decision.BlockingFlips.Count > 10)
|
||||
{
|
||||
sb.AppendLine($"- ... and {decision.BlockingFlips.Count - 10} more");
|
||||
var remaining = decision.BlockingFlips.Count - 10;
|
||||
sb.AppendLine($"- ... and {remaining.ToString(CultureInfo.InvariantCulture)} more");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -124,7 +125,7 @@ public sealed class PathExplanationService : IPathExplanationService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<ExplainedPath?> ExplainPathAsync(
|
||||
public async Task<ExplainedPath?> ExplainPathAsync(
|
||||
RichGraph graph,
|
||||
string pathId,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -145,20 +146,22 @@ public sealed class PathExplanationService : IPathExplanationService
|
||||
MaxPaths = 100
|
||||
};
|
||||
|
||||
var resultTask = ExplainAsync(graph, query, cancellationToken);
|
||||
return resultTask.ContinueWith(t =>
|
||||
var result = await ExplainAsync(graph, query, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Paths.Count == 0)
|
||||
{
|
||||
if (t.Result.Paths.Count == 0)
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// If path index specified, return that specific one
|
||||
if (parts.Length >= 3 && int.TryParse(parts[2], out var idx) && idx < t.Result.Paths.Count)
|
||||
{
|
||||
return t.Result.Paths[idx];
|
||||
}
|
||||
// If path index specified, return that specific one
|
||||
if (parts.Length >= 3 &&
|
||||
int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var idx) &&
|
||||
idx >= 0 &&
|
||||
idx < result.Paths.Count)
|
||||
{
|
||||
return result.Paths[idx];
|
||||
}
|
||||
|
||||
return t.Result.Paths[0];
|
||||
}, cancellationToken);
|
||||
return result.Paths[0];
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<RichGraphEdge>> BuildEdgeLookup(RichGraph graph)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
@@ -11,6 +12,7 @@ using StellaOps.Scanner.Reachability.Binary;
|
||||
using StellaOps.Scanner.Reachability.Runtime;
|
||||
using StellaOps.Scanner.Reachability.Services;
|
||||
using StellaOps.Scanner.Reachability.Stack;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
// Aliases to disambiguate types with same name in different namespaces
|
||||
using StackEntrypointType = StellaOps.Scanner.Reachability.Stack.EntrypointType;
|
||||
@@ -33,6 +35,7 @@ public sealed class ReachabilityEvidenceJobExecutor : IReachabilityEvidenceJobEx
|
||||
private readonly IRuntimeReachabilityCollector? _runtimeCollector;
|
||||
private readonly ILogger<ReachabilityEvidenceJobExecutor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public ReachabilityEvidenceJobExecutor(
|
||||
ICveSymbolMappingService cveSymbolService,
|
||||
@@ -42,6 +45,7 @@ public sealed class ReachabilityEvidenceJobExecutor : IReachabilityEvidenceJobEx
|
||||
ILogger<ReachabilityEvidenceJobExecutor> logger,
|
||||
IBinaryPatchVerifier? binaryPatchVerifier = null,
|
||||
IRuntimeReachabilityCollector? runtimeCollector = null,
|
||||
IGuidProvider? guidProvider = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_cveSymbolService = cveSymbolService ?? throw new ArgumentNullException(nameof(cveSymbolService));
|
||||
@@ -51,6 +55,7 @@ public sealed class ReachabilityEvidenceJobExecutor : IReachabilityEvidenceJobEx
|
||||
_binaryPatchVerifier = binaryPatchVerifier;
|
||||
_runtimeCollector = runtimeCollector;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
@@ -409,7 +414,7 @@ public sealed class ReachabilityEvidenceJobExecutor : IReachabilityEvidenceJobEx
|
||||
// Create a minimal stack with Unknown verdict
|
||||
var unknownStack = new ReachabilityStack
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Id = _guidProvider.NewGuid().ToString("N", CultureInfo.InvariantCulture),
|
||||
FindingId = $"{job.CveId}:{job.Purl}",
|
||||
Symbol = new StackVulnerableSymbol(
|
||||
Name: "unknown",
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -22,7 +23,7 @@ public sealed class ReachabilityUnionWriter
|
||||
|
||||
private static readonly JsonWriterOptions JsonOptions = new()
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Encoder = JavaScriptEncoder.Default,
|
||||
Indented = false,
|
||||
SkipValidation = false
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -94,7 +95,7 @@ public static class RichGraphSemanticExtensions
|
||||
return null;
|
||||
}
|
||||
|
||||
return double.TryParse(value, out var score) ? score : null;
|
||||
return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var score) ? score : null;
|
||||
}
|
||||
|
||||
/// <summary>Gets the confidence score.</summary>
|
||||
@@ -106,7 +107,7 @@ public static class RichGraphSemanticExtensions
|
||||
return null;
|
||||
}
|
||||
|
||||
return double.TryParse(value, out var score) ? score : null;
|
||||
return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var score) ? score : null;
|
||||
}
|
||||
|
||||
/// <summary>Checks if this node is an entrypoint.</summary>
|
||||
@@ -190,13 +191,13 @@ public sealed class RichGraphNodeSemanticBuilder
|
||||
|
||||
public RichGraphNodeSemanticBuilder WithRiskScore(double score)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.RiskScore] = score.ToString("F3");
|
||||
_attributes[RichGraphSemanticAttributes.RiskScore] = score.ToString("F3", CultureInfo.InvariantCulture);
|
||||
return this;
|
||||
}
|
||||
|
||||
public RichGraphNodeSemanticBuilder WithConfidence(double score, string tier)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.Confidence] = score.ToString("F3");
|
||||
_attributes[RichGraphSemanticAttributes.Confidence] = score.ToString("F3", CultureInfo.InvariantCulture);
|
||||
_attributes[RichGraphSemanticAttributes.ConfidenceTier] = tier;
|
||||
return this;
|
||||
}
|
||||
@@ -225,7 +226,7 @@ public sealed class RichGraphNodeSemanticBuilder
|
||||
|
||||
public RichGraphNodeSemanticBuilder WithCweId(int cweId)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.CweId] = cweId.ToString();
|
||||
_attributes[RichGraphSemanticAttributes.CweId] = cweId.ToString(CultureInfo.InvariantCulture);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -22,7 +23,7 @@ public sealed class RichGraphWriter
|
||||
|
||||
private static readonly JsonWriterOptions JsonOptions = new()
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Encoder = JavaScriptEncoder.Default,
|
||||
Indented = false,
|
||||
SkipValidation = false
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ public sealed class InMemorySliceCache : ISliceCache, IDisposable
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||
private readonly ILogger<InMemorySliceCache> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly CancellationTokenSource _evictionCts = new();
|
||||
private readonly Timer _evictionTimer;
|
||||
private readonly SemaphoreSlim _evictionLock = new(1, 1);
|
||||
|
||||
@@ -26,7 +27,7 @@ public sealed class InMemorySliceCache : ISliceCache, IDisposable
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_evictionTimer = new Timer(
|
||||
_ => _ = EvictExpiredEntriesAsync(CancellationToken.None),
|
||||
_ => _ = EvictExpiredEntriesAsync(_evictionCts.Token),
|
||||
null,
|
||||
TimeSpan.FromSeconds(EvictionIntervalSeconds),
|
||||
TimeSpan.FromSeconds(EvictionIntervalSeconds));
|
||||
@@ -116,7 +117,19 @@ public sealed class InMemorySliceCache : ISliceCache, IDisposable
|
||||
|
||||
private async Task EvictExpiredEntriesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _evictionLock.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!await _evictionLock.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -199,8 +212,14 @@ public sealed class InMemorySliceCache : ISliceCache, IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_evictionCts.IsCancellationRequested)
|
||||
{
|
||||
_evictionCts.Cancel();
|
||||
}
|
||||
|
||||
_evictionTimer?.Dispose();
|
||||
_evictionLock?.Dispose();
|
||||
_evictionCts.Dispose();
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Stack;
|
||||
@@ -48,6 +50,18 @@ public interface IReachabilityStackEvaluator
|
||||
/// </remarks>
|
||||
public sealed class ReachabilityStackEvaluator : IReachabilityStackEvaluator
|
||||
{
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public ReachabilityStackEvaluator()
|
||||
: this(SystemGuidProvider.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public ReachabilityStackEvaluator(IGuidProvider guidProvider)
|
||||
{
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ReachabilityStack Evaluate(
|
||||
string findingId,
|
||||
@@ -63,7 +77,7 @@ public sealed class ReachabilityStackEvaluator : IReachabilityStackEvaluator
|
||||
|
||||
return new ReachabilityStack
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Id = _guidProvider.NewGuid().ToString("N", CultureInfo.InvariantCulture),
|
||||
FindingId = findingId,
|
||||
Symbol = symbol,
|
||||
StaticCallGraph = layer1,
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
<ProjectReference Include="..\..\..\Attestor\__Libraries\StellaOps.Attestor.GraphRoot\StellaOps.Attestor.GraphRoot.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\Signals\__Libraries\StellaOps.Signals.Ebpf\StellaOps.Signals.Ebpf.csproj" />
|
||||
<ProjectReference Include="..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Ghidra\StellaOps.BinaryIndex.Ghidra.csproj" />
|
||||
<ProjectReference Include="..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Decompiler\StellaOps.BinaryIndex.Decompiler.csproj" />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
@@ -16,17 +18,29 @@ public class SubgraphExtractor : IReachabilityResolver
|
||||
private readonly IEntryPointResolver _entryPointResolver;
|
||||
private readonly IVulnSurfaceService _vulnSurfaceService;
|
||||
private readonly ILogger<SubgraphExtractor> _logger;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public SubgraphExtractor(
|
||||
IRichGraphStore graphStore,
|
||||
IEntryPointResolver entryPointResolver,
|
||||
IVulnSurfaceService vulnSurfaceService,
|
||||
ILogger<SubgraphExtractor> logger)
|
||||
: this(graphStore, entryPointResolver, vulnSurfaceService, logger, SystemGuidProvider.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public SubgraphExtractor(
|
||||
IRichGraphStore graphStore,
|
||||
IEntryPointResolver entryPointResolver,
|
||||
IVulnSurfaceService vulnSurfaceService,
|
||||
ILogger<SubgraphExtractor> logger,
|
||||
IGuidProvider guidProvider)
|
||||
{
|
||||
_graphStore = graphStore ?? throw new ArgumentNullException(nameof(graphStore));
|
||||
_entryPointResolver = entryPointResolver ?? throw new ArgumentNullException(nameof(entryPointResolver));
|
||||
_vulnSurfaceService = vulnSurfaceService ?? throw new ArgumentNullException(nameof(vulnSurfaceService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
public async Task<PoESubgraph?> ResolveAsync(
|
||||
@@ -206,7 +220,7 @@ public class SubgraphExtractor : IReachabilityResolver
|
||||
if (sinkSet.Contains(current))
|
||||
{
|
||||
paths.Add(new CallPath(
|
||||
PathId: Guid.NewGuid().ToString(),
|
||||
PathId: _guidProvider.NewGuid().ToString("N", CultureInfo.InvariantCulture),
|
||||
Nodes: path.ToList(),
|
||||
Edges: ExtractEdgesFromPath(path, graph),
|
||||
Length: path.Count - 1,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
@@ -11,11 +13,12 @@ namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
public sealed class SuppressionDsseSigner : ISuppressionDsseSigner
|
||||
{
|
||||
private readonly EnvelopeSignatureService _signatureService;
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -44,13 +47,10 @@ public sealed class SuppressionDsseSigner : ISuppressionDsseSigner
|
||||
try
|
||||
{
|
||||
// Serialize witness to canonical JSON bytes
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(witness, CanonicalJsonOptions);
|
||||
var payloadBytes = CanonJson.Canonicalize(witness, CanonicalJsonOptions);
|
||||
|
||||
// Build the PAE (Pre-Authentication Encoding) for DSSE
|
||||
var pae = BuildPae(SuppressionWitnessSchema.DssePayloadType, payloadBytes);
|
||||
|
||||
// Sign the PAE
|
||||
var signResult = _signatureService.Sign(pae, signingKey, cancellationToken);
|
||||
// Sign DSSE payload using the shared PAE helper
|
||||
var signResult = _signatureService.SignDsse(SuppressionWitnessSchema.DssePayloadType, payloadBytes, signingKey, cancellationToken);
|
||||
if (!signResult.IsSuccess)
|
||||
{
|
||||
return SuppressionDsseResult.Failure($"Signing failed: {signResult.Error?.Message}");
|
||||
@@ -114,12 +114,10 @@ public sealed class SuppressionDsseSigner : ISuppressionDsseSigner
|
||||
return SuppressionVerifyResult.Failure($"No signature found for key ID: {publicKey.KeyId}");
|
||||
}
|
||||
|
||||
// Build PAE and verify signature
|
||||
var pae = BuildPae(envelope.PayloadType, envelope.Payload.ToArray());
|
||||
var signatureBytes = Convert.FromBase64String(matchingSignature.Signature);
|
||||
var envelopeSignature = new EnvelopeSignature(publicKey.KeyId, publicKey.AlgorithmId, signatureBytes);
|
||||
|
||||
var verifyResult = _signatureService.Verify(pae, envelopeSignature, publicKey, cancellationToken);
|
||||
var verifyResult = _signatureService.VerifyDsse(envelope.PayloadType, envelope.Payload.Span, envelopeSignature, publicKey, cancellationToken);
|
||||
if (!verifyResult.IsSuccess)
|
||||
{
|
||||
return SuppressionVerifyResult.Failure($"Signature verification failed: {verifyResult.Error?.Message}");
|
||||
@@ -133,43 +131,6 @@ public sealed class SuppressionDsseSigner : ISuppressionDsseSigner
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the DSSE Pre-Authentication Encoding (PAE) for a payload.
|
||||
/// PAE = "DSSEv1" SP len(type) SP type SP len(payload) SP payload
|
||||
/// </summary>
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
// Write "DSSEv1 "
|
||||
writer.Write(Encoding.UTF8.GetBytes("DSSEv1 "));
|
||||
|
||||
// Write len(type) as ASCII decimal string followed by space
|
||||
WriteLengthAndSpace(writer, typeBytes.Length);
|
||||
|
||||
// Write type followed by space
|
||||
writer.Write(typeBytes);
|
||||
writer.Write((byte)' ');
|
||||
|
||||
// Write len(payload) as ASCII decimal string followed by space
|
||||
WriteLengthAndSpace(writer, payload.Length);
|
||||
|
||||
// Write payload
|
||||
writer.Write(payload);
|
||||
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteLengthAndSpace(BinaryWriter writer, int length)
|
||||
{
|
||||
// Write length as ASCII decimal string
|
||||
writer.Write(Encoding.UTF8.GetBytes(length.ToString()));
|
||||
writer.Write((byte)' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
@@ -11,11 +13,12 @@ namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
public sealed class WitnessDsseSigner : IWitnessDsseSigner
|
||||
{
|
||||
private readonly EnvelopeSignatureService _signatureService;
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -44,13 +47,10 @@ public sealed class WitnessDsseSigner : IWitnessDsseSigner
|
||||
try
|
||||
{
|
||||
// Serialize witness to canonical JSON bytes
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(witness, CanonicalJsonOptions);
|
||||
var payloadBytes = CanonJson.Canonicalize(witness, CanonicalJsonOptions);
|
||||
|
||||
// Build the PAE (Pre-Authentication Encoding) for DSSE
|
||||
var pae = BuildPae(WitnessSchema.DssePayloadType, payloadBytes);
|
||||
|
||||
// Sign the PAE
|
||||
var signResult = _signatureService.Sign(pae, signingKey, cancellationToken);
|
||||
// Sign DSSE payload using the shared PAE helper
|
||||
var signResult = _signatureService.SignDsse(WitnessSchema.DssePayloadType, payloadBytes, signingKey, cancellationToken);
|
||||
if (!signResult.IsSuccess)
|
||||
{
|
||||
return WitnessDsseResult.Failure($"Signing failed: {signResult.Error?.Message}");
|
||||
@@ -114,12 +114,10 @@ public sealed class WitnessDsseSigner : IWitnessDsseSigner
|
||||
return WitnessVerifyResult.Failure($"No signature found for key ID: {publicKey.KeyId}");
|
||||
}
|
||||
|
||||
// Build PAE and verify signature
|
||||
var pae = BuildPae(envelope.PayloadType, envelope.Payload.ToArray());
|
||||
var signatureBytes = Convert.FromBase64String(matchingSignature.Signature);
|
||||
var envelopeSignature = new EnvelopeSignature(publicKey.KeyId, publicKey.AlgorithmId, signatureBytes);
|
||||
|
||||
var verifyResult = _signatureService.Verify(pae, envelopeSignature, publicKey, cancellationToken);
|
||||
var verifyResult = _signatureService.VerifyDsse(envelope.PayloadType, envelope.Payload.Span, envelopeSignature, publicKey, cancellationToken);
|
||||
if (!verifyResult.IsSuccess)
|
||||
{
|
||||
return WitnessVerifyResult.Failure($"Signature verification failed: {verifyResult.Error?.Message}");
|
||||
@@ -133,43 +131,6 @@ public sealed class WitnessDsseSigner : IWitnessDsseSigner
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the DSSE Pre-Authentication Encoding (PAE) for a payload.
|
||||
/// PAE = "DSSEv1" SP len(type) SP type SP len(payload) SP payload
|
||||
/// </summary>
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
// Write "DSSEv1 "
|
||||
writer.Write(Encoding.UTF8.GetBytes("DSSEv1 "));
|
||||
|
||||
// Write len(type) as little-endian 8-byte integer followed by space
|
||||
WriteLengthAndSpace(writer, typeBytes.Length);
|
||||
|
||||
// Write type followed by space
|
||||
writer.Write(typeBytes);
|
||||
writer.Write((byte)' ');
|
||||
|
||||
// Write len(payload) as little-endian 8-byte integer followed by space
|
||||
WriteLengthAndSpace(writer, payload.Length);
|
||||
|
||||
// Write payload
|
||||
writer.Write(payload);
|
||||
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteLengthAndSpace(BinaryWriter writer, int length)
|
||||
{
|
||||
// Write length as ASCII decimal string
|
||||
writer.Write(Encoding.UTF8.GetBytes(length.ToString()));
|
||||
writer.Write((byte)' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
public interface ISurfaceSecretProvider
|
||||
{
|
||||
SurfaceSecretHandle Get(SurfaceSecretRequest request);
|
||||
|
||||
ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -29,6 +29,50 @@ internal sealed class AuditingSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
_componentName = componentName ?? throw new ArgumentNullException(nameof(componentName));
|
||||
}
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var handle = _inner.Get(request);
|
||||
|
||||
var elapsed = _timeProvider.GetUtcNow() - startTime;
|
||||
LogAuditEvent(
|
||||
request,
|
||||
handle.Metadata,
|
||||
success: true,
|
||||
elapsed,
|
||||
error: null);
|
||||
|
||||
return handle;
|
||||
}
|
||||
catch (SurfaceSecretNotFoundException)
|
||||
{
|
||||
var elapsed = _timeProvider.GetUtcNow() - startTime;
|
||||
LogAuditEvent(
|
||||
request,
|
||||
metadata: null,
|
||||
success: false,
|
||||
elapsed,
|
||||
error: "NotFound");
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var elapsed = _timeProvider.GetUtcNow() - startTime;
|
||||
LogAuditEvent(
|
||||
request,
|
||||
metadata: null,
|
||||
success: false,
|
||||
elapsed,
|
||||
error: ex.GetType().Name);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -35,6 +35,25 @@ internal sealed class CachingSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
|
||||
public TimeSpan CacheTtl => _ttl;
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
var key = BuildCacheKey(request);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > now)
|
||||
{
|
||||
_logger.LogDebug("Surface secret cache hit for {Key}.", key);
|
||||
return entry.Handle;
|
||||
}
|
||||
|
||||
var handle = _inner.Get(request);
|
||||
var newEntry = new CacheEntry(handle, now.Add(_ttl));
|
||||
_cache[key] = newEntry;
|
||||
|
||||
_logger.LogDebug("Surface secret cached for {Key}, expires at {ExpiresAt}.", key, newEntry.ExpiresAt);
|
||||
return handle;
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -18,6 +18,23 @@ internal sealed class CompositeSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
}
|
||||
}
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
foreach (var provider in _providers)
|
||||
{
|
||||
try
|
||||
{
|
||||
return provider.Get(request);
|
||||
}
|
||||
catch (SurfaceSecretNotFoundException)
|
||||
{
|
||||
// try next provider
|
||||
}
|
||||
}
|
||||
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -20,6 +20,35 @@ internal sealed class FileSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
_root = root;
|
||||
}
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var path = ResolvePath(request);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
var descriptor = JsonSerializer.Deserialize<FileSecretDescriptor>(json);
|
||||
if (descriptor is null)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(descriptor.Payload))
|
||||
{
|
||||
return SurfaceSecretHandle.Empty;
|
||||
}
|
||||
|
||||
var bytes = Convert.FromBase64String(descriptor.Payload);
|
||||
return SurfaceSecretHandle.FromBytes(bytes, descriptor.Metadata);
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -21,6 +21,21 @@ public sealed class InMemorySurfaceSecretProvider : ISurfaceSecretProvider
|
||||
_secrets[request.CacheKey] = handle;
|
||||
}
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (_secrets.TryGetValue(request.CacheKey, out var handle))
|
||||
{
|
||||
return handle;
|
||||
}
|
||||
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
|
||||
@@ -14,6 +14,30 @@ internal sealed class InlineSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
if (!_configuration.AllowInline)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var envKey = BuildEnvironmentKey(request);
|
||||
var value = Environment.GetEnvironmentVariable(envKey);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var bytes = Convert.FromBase64String(value);
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["source"] = "inline-env",
|
||||
["key"] = envKey
|
||||
};
|
||||
|
||||
return SurfaceSecretHandle.FromBytes(bytes, metadata);
|
||||
}
|
||||
|
||||
public ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -23,6 +23,30 @@ internal sealed class KubernetesSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
}
|
||||
}
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
var directory = Path.Combine(_configuration.Root!, request.Tenant, request.Component, request.SecretType);
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
_logger.LogDebug("Kubernetes secret directory {Directory} not found.", directory);
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var name = request.Name ?? "default";
|
||||
var payloadPath = Path.Combine(directory, name);
|
||||
if (!File.Exists(payloadPath))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var bytes = File.ReadAllBytes(payloadPath);
|
||||
return SurfaceSecretHandle.FromBytes(bytes, new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "kubernetes",
|
||||
["path"] = payloadPath
|
||||
});
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -42,6 +42,73 @@ internal sealed class OfflineSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
}
|
||||
}
|
||||
|
||||
public SurfaceSecretHandle Get(SurfaceSecretRequest request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var directory = Path.Combine(_root, request.Tenant, request.Component, request.SecretType);
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
_logger.LogDebug("Offline secret directory {Directory} not found.", directory);
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
// Deterministic selection: if name is specified use it, otherwise pick lexicographically smallest
|
||||
var targetName = request.Name ?? SelectDeterministicName(directory);
|
||||
if (targetName is null)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var path = Path.Combine(directory, targetName + ".json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
var descriptor = JsonSerializer.Deserialize<OfflineSecretDescriptor>(json);
|
||||
if (descriptor is null)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(descriptor.Payload))
|
||||
{
|
||||
return SurfaceSecretHandle.Empty;
|
||||
}
|
||||
|
||||
var bytes = Convert.FromBase64String(descriptor.Payload);
|
||||
|
||||
// Verify integrity if manifest entry exists
|
||||
var manifestKey = BuildManifestKey(request.Tenant, request.Component, request.SecretType, targetName);
|
||||
if (_manifest?.TryGetValue(manifestKey, out var entry) == true)
|
||||
{
|
||||
var actualHash = ComputeSha256(bytes);
|
||||
if (!string.Equals(actualHash, entry.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Offline secret integrity check failed for {Key}. Expected {Expected}, got {Actual}.",
|
||||
manifestKey,
|
||||
entry.Sha256,
|
||||
actualHash);
|
||||
throw new InvalidOperationException($"Offline secret integrity check failed for {manifestKey}.");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Offline secret integrity verified for {Key}.", manifestKey);
|
||||
}
|
||||
|
||||
var metadata = descriptor.Metadata ?? new Dictionary<string, string>();
|
||||
metadata["source"] = "offline";
|
||||
metadata["path"] = path;
|
||||
metadata["name"] = targetName;
|
||||
|
||||
return SurfaceSecretHandle.FromBytes(bytes, metadata);
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
Reference in New Issue
Block a user