partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -0,0 +1,193 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using StellaOps.Integrations.Contracts.AiCodeGuard;
namespace StellaOps.Integrations.WebService.AiCodeGuard;
public interface IAiCodeGuardPipelineConfigLoader
{
AiCodeGuardRunConfiguration Load(string? yaml);
}
/// <summary>
/// Minimal deterministic YAML loader for AI Code Guard pipeline settings.
/// </summary>
public sealed class AiCodeGuardPipelineConfigLoader : IAiCodeGuardPipelineConfigLoader
{
public AiCodeGuardRunConfiguration Load(string? yaml)
{
if (string.IsNullOrWhiteSpace(yaml))
{
return new AiCodeGuardRunConfiguration();
}
var enableSecrets = true;
var enableAttribution = true;
var enableLicense = true;
var maxFindings = 200;
var allowedLicenses = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var customPatterns = new HashSet<string>(StringComparer.Ordinal);
string? activeList = null;
foreach (var rawLine in EnumerateLines(yaml))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith('#'))
{
continue;
}
if (line.StartsWith("-", StringComparison.Ordinal))
{
if (activeList is null)
{
throw new FormatException($"List entry is not attached to a key: '{line}'.");
}
var item = line[1..].Trim();
if (string.IsNullOrWhiteSpace(item))
{
continue;
}
switch (activeList)
{
case "allowedspdxlicenses":
allowedLicenses.Add(item);
break;
case "customsecretpatterns":
ValidateRegexPattern(item);
customPatterns.Add(item);
break;
default:
throw new FormatException($"Unsupported list key '{activeList}'.");
}
continue;
}
var separatorIndex = line.IndexOf(':');
if (separatorIndex < 0)
{
throw new FormatException($"Invalid YAML entry '{line}'. Expected key:value.");
}
var key = NormalizeKey(line[..separatorIndex]);
var value = line[(separatorIndex + 1)..].Trim();
if (value.Length == 0)
{
activeList = key switch
{
"allowedspdxlicenses" or "licenseallowlist" => "allowedspdxlicenses",
"customsecretpatterns" or "secretpatterns" => "customsecretpatterns",
_ => throw new FormatException($"Unsupported list key '{key}'."),
};
continue;
}
activeList = null;
switch (key)
{
case "enablesecretsscan":
case "secrets":
enableSecrets = ParseBoolean(value, key);
break;
case "enableattributioncheck":
case "attribution":
enableAttribution = ParseBoolean(value, key);
break;
case "enablelicensehygiene":
case "license":
enableLicense = ParseBoolean(value, key);
break;
case "maxfindings":
maxFindings = ParseMaxFindings(value);
break;
case "allowedspdxlicenses":
case "licenseallowlist":
foreach (var item in ParseInlineList(value))
{
allowedLicenses.Add(item);
}
break;
case "customsecretpatterns":
case "secretpatterns":
foreach (var pattern in ParseInlineList(value))
{
ValidateRegexPattern(pattern);
customPatterns.Add(pattern);
}
break;
default:
throw new FormatException($"Unsupported configuration key '{key}'.");
}
}
return new AiCodeGuardRunConfiguration
{
EnableSecretsScan = enableSecrets,
EnableAttributionCheck = enableAttribution,
EnableLicenseHygiene = enableLicense,
MaxFindings = maxFindings,
AllowedSpdxLicenses = allowedLicenses
.OrderBy(static value => value, StringComparer.Ordinal)
.ToImmutableArray(),
CustomSecretPatterns = customPatterns
.OrderBy(static value => value, StringComparer.Ordinal)
.ToImmutableArray(),
};
}
private static IEnumerable<string> EnumerateLines(string yaml)
{
return yaml
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Split('\n');
}
private static string NormalizeKey(string key)
{
return key.Trim().ToLowerInvariant().Replace("_", string.Empty, StringComparison.Ordinal);
}
private static bool ParseBoolean(string value, string key)
{
return value.Trim().ToLowerInvariant() switch
{
"true" or "yes" or "on" => true,
"false" or "no" or "off" => false,
_ => throw new FormatException($"Invalid boolean value '{value}' for key '{key}'."),
};
}
private static int ParseMaxFindings(string value)
{
if (!int.TryParse(value, out var parsed) || parsed < 1)
{
throw new FormatException($"Invalid maxFindings value '{value}'. Expected positive integer.");
}
return parsed;
}
private static IEnumerable<string> ParseInlineList(string value)
{
var normalized = value.Trim();
if (normalized.StartsWith('[') && normalized.EndsWith(']'))
{
normalized = normalized[1..^1];
}
return normalized
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(static entry => !string.IsNullOrWhiteSpace(entry))
.Select(static entry => entry.Trim('"', '\''));
}
private static void ValidateRegexPattern(string pattern)
{
_ = new Regex(pattern, RegexOptions.CultureInvariant);
}
}

View File

@@ -0,0 +1,396 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using StellaOps.Integrations.Contracts.AiCodeGuard;
namespace StellaOps.Integrations.WebService.AiCodeGuard;
public interface IAiCodeGuardRunService
{
Task<AiCodeGuardRunResponse> RunAsync(
AiCodeGuardRunRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Deterministic standalone AI Code Guard runner for secrets, attribution, and license hygiene.
/// </summary>
public sealed class AiCodeGuardRunService : IAiCodeGuardRunService
{
private static readonly Regex SpdxLicenseRegex = new(
@"SPDX-License-Identifier:\s*(?<id>[A-Za-z0-9\.\-\+]+)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly ImmutableArray<SecretRule> BuiltInSecretRules =
[
new(
"AICG-SECRET-AWS-ACCESS-KEY",
"Secrets",
"Potential AWS access key detected.",
new Regex(@"AKIA[0-9A-Z]{16}", RegexOptions.CultureInvariant),
AnnotationLevel.Failure,
0.98),
new(
"AICG-SECRET-GITHUB-TOKEN",
"Secrets",
"Potential GitHub personal access token detected.",
new Regex(@"ghp_[A-Za-z0-9]{36}", RegexOptions.CultureInvariant),
AnnotationLevel.Failure,
0.95),
new(
"AICG-SECRET-PRIVATE-KEY",
"Secrets",
"Private key material detected.",
new Regex(@"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", RegexOptions.CultureInvariant),
AnnotationLevel.Failure,
0.99),
];
private static readonly ImmutableArray<string> AttributionMarkers =
[
"generated by chatgpt",
"generated with chatgpt",
"copilot",
"ai-generated",
"generated by ai",
];
private readonly IAiCodeGuardPipelineConfigLoader _configLoader;
private readonly ILogger<AiCodeGuardRunService> _logger;
public AiCodeGuardRunService(
IAiCodeGuardPipelineConfigLoader configLoader,
ILogger<AiCodeGuardRunService> logger)
{
_configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<AiCodeGuardRunResponse> RunAsync(
AiCodeGuardRunRequest request,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
ArgumentNullException.ThrowIfNull(request);
var configuration = _configLoader.Load(request.ConfigYaml);
var files = request.Files
.OrderBy(static file => NormalizePath(file.Path), StringComparer.Ordinal)
.ToArray();
var findings = new List<AiCodeGuardFindingAnnotation>();
var filesWithFindings = new HashSet<string>(StringComparer.Ordinal);
var attributionFiles = new HashSet<string>(StringComparer.Ordinal);
var secretRules = BuildSecretRules(configuration);
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
var path = NormalizePath(file.Path);
var lines = GetLines(file.Content);
var findingsBefore = findings.Count;
if (configuration.EnableSecretsScan)
{
ScanSecrets(path, lines, secretRules, findings);
}
if (configuration.EnableAttributionCheck)
{
ScanAttribution(path, lines, findings, attributionFiles);
}
if (configuration.EnableLicenseHygiene)
{
ScanLicense(path, lines, configuration.AllowedSpdxLicenses, findings);
}
if (findings.Count > findingsBefore)
{
filesWithFindings.Add(path);
}
}
var orderedFindings = findings
.OrderByDescending(static finding => GetSeverityWeight(finding.Level))
.ThenBy(static finding => finding.Path, StringComparer.Ordinal)
.ThenBy(static finding => finding.StartLine)
.ThenBy(static finding => finding.RuleId, StringComparer.Ordinal)
.ThenBy(static finding => finding.Id, StringComparer.Ordinal)
.Take(configuration.MaxFindings)
.ToImmutableArray();
var summary = BuildSummary(
orderedFindings,
files.Length,
filesWithFindings.Count,
attributionFiles.Count);
var status = DetermineStatus(orderedFindings);
_logger.LogInformation(
"AI Code Guard run completed for {Owner}/{Repo}@{Sha}: {Status}, findings={Count}",
request.Owner,
request.Repo,
request.CommitSha,
status,
orderedFindings.Length);
return Task.FromResult(new AiCodeGuardRunResponse
{
Status = status,
Summary = summary,
Findings = orderedFindings,
Configuration = configuration,
});
}
private static ImmutableArray<SecretRule> BuildSecretRules(AiCodeGuardRunConfiguration configuration)
{
var builder = ImmutableArray.CreateBuilder<SecretRule>(BuiltInSecretRules.Length + configuration.CustomSecretPatterns.Length);
builder.AddRange(BuiltInSecretRules);
for (var index = 0; index < configuration.CustomSecretPatterns.Length; index++)
{
var pattern = configuration.CustomSecretPatterns[index];
var ruleId = $"AICG-SECRET-CUSTOM-{index + 1:D2}";
builder.Add(new SecretRule(
ruleId,
"Secrets",
$"Custom secret pattern matched ({ruleId}).",
new Regex(pattern, RegexOptions.CultureInvariant),
AnnotationLevel.Failure,
0.90));
}
return builder.ToImmutable();
}
private static void ScanSecrets(
string path,
IReadOnlyList<string> lines,
ImmutableArray<SecretRule> rules,
ICollection<AiCodeGuardFindingAnnotation> findings)
{
for (var lineNumber = 0; lineNumber < lines.Count; lineNumber++)
{
var line = lines[lineNumber];
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
foreach (var rule in rules)
{
if (!rule.Pattern.IsMatch(line))
{
continue;
}
findings.Add(CreateFinding(
rule.RuleId,
path,
lineNumber + 1,
rule.Level,
rule.Category,
rule.Message,
rule.Confidence));
}
}
}
private static void ScanAttribution(
string path,
IReadOnlyList<string> lines,
ICollection<AiCodeGuardFindingAnnotation> findings,
ISet<string> attributionFiles)
{
for (var lineNumber = 0; lineNumber < lines.Count; lineNumber++)
{
var line = lines[lineNumber];
foreach (var marker in AttributionMarkers)
{
if (line.IndexOf(marker, StringComparison.OrdinalIgnoreCase) < 0)
{
continue;
}
attributionFiles.Add(path);
findings.Add(CreateFinding(
"AICG-ATTRIBUTION-MARKER",
path,
lineNumber + 1,
AnnotationLevel.Warning,
"Attribution",
$"AI attribution marker '{marker}' detected.",
0.80));
break;
}
}
}
private static void ScanLicense(
string path,
IReadOnlyList<string> lines,
ImmutableArray<string> allowedLicenses,
ICollection<AiCodeGuardFindingAnnotation> findings)
{
var upperBound = Math.Min(lines.Count, 20);
var discoveredLicense = default(string);
var discoveredLine = 1;
for (var lineNumber = 0; lineNumber < upperBound; lineNumber++)
{
var match = SpdxLicenseRegex.Match(lines[lineNumber]);
if (!match.Success)
{
continue;
}
discoveredLicense = match.Groups["id"].Value.Trim();
discoveredLine = lineNumber + 1;
break;
}
if (string.IsNullOrWhiteSpace(discoveredLicense))
{
findings.Add(CreateFinding(
"AICG-LICENSE-MISSING",
path,
1,
AnnotationLevel.Warning,
"License",
"SPDX-License-Identifier header is missing.",
0.85));
return;
}
if (allowedLicenses.Length == 0)
{
return;
}
var isAllowed = allowedLicenses.Contains(discoveredLicense, StringComparer.OrdinalIgnoreCase);
if (isAllowed)
{
return;
}
findings.Add(CreateFinding(
"AICG-LICENSE-DISALLOWED",
path,
discoveredLine,
AnnotationLevel.Failure,
"License",
$"SPDX license '{discoveredLicense}' is not in the allow list.",
0.92));
}
private static AiCodeGuardSummary BuildSummary(
ImmutableArray<AiCodeGuardFindingAnnotation> findings,
int filesAnalyzed,
int filesWithFindings,
int attributionFileCount)
{
var critical = findings.Count(static finding => finding.Level == AnnotationLevel.Failure && finding.Category == "Secrets");
var high = findings.Count(static finding => finding.Level == AnnotationLevel.Failure && finding.Category != "Secrets");
var medium = findings.Count(static finding => finding.Level == AnnotationLevel.Warning);
var info = findings.Count(static finding => finding.Level == AnnotationLevel.Notice);
double? aiGeneratedPercentage = filesAnalyzed == 0
? null
: Math.Round((double)attributionFileCount * 100 / filesAnalyzed, 1, MidpointRounding.AwayFromZero);
return new AiCodeGuardSummary
{
TotalFindings = findings.Length,
Critical = critical,
High = high,
Medium = medium,
Low = 0,
Info = info,
AiGeneratedPercentage = aiGeneratedPercentage,
FilesWithFindings = filesWithFindings,
FilesAnalyzed = filesAnalyzed,
};
}
private static AiCodeGuardAnalysisStatus DetermineStatus(ImmutableArray<AiCodeGuardFindingAnnotation> findings)
{
if (findings.Any(static finding => finding.Level == AnnotationLevel.Failure))
{
return AiCodeGuardAnalysisStatus.Fail;
}
if (findings.Any(static finding => finding.Level == AnnotationLevel.Warning))
{
return AiCodeGuardAnalysisStatus.Warning;
}
return AiCodeGuardAnalysisStatus.Pass;
}
private static int GetSeverityWeight(AnnotationLevel level)
{
return level switch
{
AnnotationLevel.Failure => 3,
AnnotationLevel.Warning => 2,
AnnotationLevel.Notice => 1,
_ => 0,
};
}
private static string NormalizePath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Source file path is required.", nameof(path));
}
return path.Trim().Replace('\\', '/');
}
private static IReadOnlyList<string> GetLines(string content)
{
return (content ?? string.Empty)
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Split('\n');
}
private static AiCodeGuardFindingAnnotation CreateFinding(
string ruleId,
string path,
int line,
AnnotationLevel level,
string category,
string message,
double confidence)
{
var canonical = $"{ruleId}|{path}|{line}|{message}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
var findingId = Convert.ToHexString(hash).ToLowerInvariant()[..16];
return new AiCodeGuardFindingAnnotation
{
Id = findingId,
Path = path,
StartLine = line,
EndLine = line,
Level = level,
Category = category,
Message = message,
RuleId = ruleId,
Confidence = confidence,
};
}
private sealed record SecretRule(
string RuleId,
string Category,
string Message,
Regex Pattern,
AnnotationLevel Level,
double Confidence);
}

View File

@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Contracts.AiCodeGuard;
using StellaOps.Integrations.Core;
using StellaOps.Integrations.WebService.AiCodeGuard;
namespace StellaOps.Integrations.WebService;
@@ -14,6 +16,18 @@ public static class IntegrationEndpoints
var group = app.MapGroup("/api/v1/integrations")
.WithTags("Integrations");
// Standalone AI Code Guard run
group.MapPost("/ai-code-guard/run", async (
[FromServices] IAiCodeGuardRunService aiCodeGuardRunService,
[FromBody] AiCodeGuardRunRequest request,
CancellationToken cancellationToken) =>
{
var response = await aiCodeGuardRunService.RunAsync(request, cancellationToken);
return Results.Ok(response);
})
.WithName("RunAiCodeGuard")
.WithDescription("Runs standalone AI Code Guard checks (equivalent to stella guard run).");
// List integrations
group.MapGet("/", async (
[FromServices] IntegrationService service,

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Integrations.Persistence;
using StellaOps.Integrations.WebService;
using StellaOps.Integrations.WebService.AiCodeGuard;
using StellaOps.Integrations.WebService.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
@@ -51,6 +52,8 @@ builder.Services.AddScoped<IAuthRefResolver, StubAuthRefResolver>();
// Core service
builder.Services.AddScoped<IntegrationService>();
builder.Services.AddSingleton<IAiCodeGuardPipelineConfigLoader, AiCodeGuardPipelineConfigLoader>();
builder.Services.AddScoped<IAiCodeGuardRunService, AiCodeGuardRunService>();
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);

View File

@@ -1,4 +1,4 @@
# StellaOps.Integrations.WebService Task Board
# StellaOps.Integrations.WebService Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
@@ -6,3 +6,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Integrations/StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT_20260208_040-WEB | DONE | AI Code Guard run endpoint + service wiring in Integrations WebService. |