diff --git a/docs/architecture/IMPLEMENTATION_STATUS.md b/docs/architecture/IMPLEMENTATION_STATUS.md index c02313d..ce545c9 100644 --- a/docs/architecture/IMPLEMENTATION_STATUS.md +++ b/docs/architecture/IMPLEMENTATION_STATUS.md @@ -745,6 +745,30 @@ Decisions and gotchas: - `JsonSchema.Net` is referenced by `CodeGuard.Core.Tests` only, to validate output. Each schema is parsed once in a static `Lazy`, because `JsonSchema.FromText` throws on a duplicate `$id`. +### Post-v1 addition: repository, commit and rule-source provenance (#67) + +The JSON result (`validation-report.v1`) gained optional `repository`, `commit`, `branch` and +`ruleSources`. SARIF gained `tool.driver.version`, `run.versionControlProvenance` (when the repository +URI is known) and the rule sources in the run property bag `ruleSources`. Full field list: +`docs/architecture/RESULT_CONTRACT.md`. + +Decisions and gotchas: + +- Reporting never shells out. `CodeGuard.Cli.Support.ValidationProvenanceGatherer` gathers the + values and passes a `CodeGuard.Reporting.Provenance.ValidationProvenance` into the JSON and SARIF + reporter constructors. Repository access and environment lookup are injected (`IGitCommandRunner`, + an env lookup function) so precedence is unit-tested. +- Precedence is per field: a CI variable wins, and the repository fills in what CI does not provide. + GitHub uses `GITHUB_HEAD_REF` before `GITHUB_REF_NAME`, so a PR reports its source branch rather + than `N/merge`. `GITHUB_SHA` on a PR is the merge commit, not the PR head. +- Remotes are always normalised by `RepositoryUrlNormalizer`, including CI-provided URLs, because + Azure and GitLab can embed credentials. No raw remote reaches a report. +- SARIF `versionControlProvenance` requires `repositoryUri`, so a commit with no known remote is not + written there. The commit still appears in the JSON output. +- The process helper is `GitRuleSourceSync.RunCore`, now `internal`, so provenance and rule-cache + sync share one process runner. A missing git binary or a non-repository directory yields nulls and + never fails validation. + ### Post-v1 addition: violation end positions and metadata (#68) `Violation` (and `AnalyzerViolation`) gained optional `EndLine`/`EndColumn` (`int?`, default `null`) diff --git a/docs/architecture/RESULT_CONTRACT.md b/docs/architecture/RESULT_CONTRACT.md index 895e309..613db4d 100644 --- a/docs/architecture/RESULT_CONTRACT.md +++ b/docs/architecture/RESULT_CONTRACT.md @@ -50,7 +50,13 @@ object has `additionalProperties: false`, so a consumer can rely on the set of k } ], "evaluationErrors": [], - "analysisWarnings": [] + "analysisWarnings": [], + "repository": "https://github.com/acme/app", + "commit": "9f1c2e4b7a0d3c5e6f8a1b2c3d4e5f6a7b8c9d0e", + "branch": "main", + "ruleSources": [ + { "kind": "git", "location": "https://github.com/acme/codeguard-rules", "commit": "8f31c7a0e1d2c3b4a5f6e7d8c9b0a1f2e3d4c5b6" } + ] } ``` @@ -127,6 +133,22 @@ The earlier output was the internal `ValidationResult` serialised directly. Cons ## Not yet in the contract -Planned by the roadmap but not part of v1: `rulesVersion`, `repository`, `commit`, `branch`, a -`timestamp` separate from `evaluatedAtUtc`, and per-run size metrics such as projects and types -analysed. These need the run context to be passed into the reporter. +Planned by the roadmap but not part of v1: `rulesVersion`, a `timestamp` separate from +`evaluatedAtUtc`, and per-run size metrics such as projects and types analysed. These need the run +context to be passed into the reporter. + +## Provenance (optional fields) + +These were added to v1 as optional fields, so `schemaVersion` stays `1.0`. They are omitted when +unknown, and omitted entirely when the caller supplies no provenance (for example a library caller). + +| Field | Type | Meaning | +|---|---|---| +| `repository` | string | Normalised remote URL of the analysed repository. Userinfo (`user:token@`), query and fragment are removed, and a trailing `.git` is dropped. scp-style remotes become `ssh://host/path`. | +| `commit` | string | Commit SHA of the analysed checkout. | +| `branch` | string | Branch of the analysed checkout. Omitted for a detached HEAD outside CI. | +| `ruleSources` | array | One entry per rules location the run used: `kind` (`git` or `directory`), `location` (git URL with credentials removed, or a directory path), and `commit` (HEAD of the rules cache, git only). | + +Values come from the CI environment when a known variable is set (GitHub Actions, GitLab CI, Azure +Pipelines), and otherwise from `git` in the repository root, field by field. Rule-source `commit` +is read from the rules cache clone, so it identifies the exact rules used. diff --git a/src/CodeGuard.Cli/Commands/ValidateCommand.cs b/src/CodeGuard.Cli/Commands/ValidateCommand.cs index 1699800..135187b 100644 --- a/src/CodeGuard.Cli/Commands/ValidateCommand.cs +++ b/src/CodeGuard.Cli/Commands/ValidateCommand.cs @@ -11,6 +11,7 @@ using CodeGuard.Reporting.Contracts; using CodeGuard.Reporting.Html; using CodeGuard.Reporting.Json; +using CodeGuard.Reporting.Provenance; using CodeGuard.Reporting.Sarif; using CodeGuard.RuleModel.Rules; using Microsoft.Extensions.Logging; @@ -208,8 +209,10 @@ public static Command Build() consoleOutputRedirected: Console.IsOutputRedirected, noColorEnvVar: Environment.GetEnvironmentVariable("NO_COLOR")); + var provenance = ValidationProvenanceGatherer.Default.Gather(context.RepoRoot, context.RuleSources); + var reporter = CreateReporter( - parseResult.GetValue(formatOption)!, useColor, totalStopwatch.Elapsed); + parseResult.GetValue(formatOption)!, useColor, totalStopwatch.Elapsed, provenance); logger.LogDebug("Writing {Format} report to {Destination}", parseResult.GetValue(formatOption), outputPath ?? "stdout"); @@ -264,14 +267,18 @@ private static ValidationResult ApplySeverityThreshold(ValidationResult result, } // durationMs in the JSON report is measured up to this point, i.e. just before the report is written. - private static IViolationReporter CreateReporter(string format, bool useColor, TimeSpan duration) => format switch + private static IViolationReporter CreateReporter( + string format, bool useColor, TimeSpan duration, ValidationProvenance provenance) { - "json" => new JsonViolationReporter( - ValidationReportMapper.ResolveToolVersion(typeof(ValidateCommand).Assembly), duration), - "sarif" => new SarifViolationReporter(), - "html" => new HtmlViolationReporter(), - _ => new ConsoleViolationReporter(useColor) - }; + var toolVersion = ValidationReportMapper.ResolveToolVersion(typeof(ValidateCommand).Assembly); + return format switch + { + "json" => new JsonViolationReporter(toolVersion, duration, provenance), + "sarif" => new SarifViolationReporter(provenance, toolVersion), + "html" => new HtmlViolationReporter(), + _ => new ConsoleViolationReporter(useColor) + }; + } private static Severity ParseSeverity(string value) => value switch { diff --git a/src/CodeGuard.Cli/Support/CliRepositoryContext.cs b/src/CodeGuard.Cli/Support/CliRepositoryContext.cs index 6951ccf..9eaea68 100644 --- a/src/CodeGuard.Cli/Support/CliRepositoryContext.cs +++ b/src/CodeGuard.Cli/Support/CliRepositoryContext.cs @@ -33,6 +33,9 @@ public sealed class CliRepositoryContext public GlobalSettings? GlobalSettings { get; init; } public ILoggerFactory? LoggerFactory { get; init; } + /// The rules locations the run is evaluated against, for result provenance. + public IReadOnlyList RuleSources { get; init; } = []; + /// /// Overrides where tier 4 looks for a prior `setup` run's settings.yml and git cache /// (default: the real OS app-data root, ). Exists @@ -61,12 +64,14 @@ public static CliRepositoryContext Resolve( ? RulesSourceProvenance.RepositoryConfig : RulesSourceProvenance.Default; + RuleSourceReference? explicitSource = null; if (rulesSource is not null) { logger.LogDebug("Using ad-hoc --rules-source override: {RulesSource} (branch {Branch})", rulesSource, branch ?? "(default)"); var overridePath = RuleSourceResolver.ResolveToLocalPath( rulesSource, branch, cacheRootOverride: globalSettingsRoot, logger: loggerFactory?.CreateLogger(typeof(RuleSourceResolver))); config = WithRules(config, [overridePath]); + explicitSource = new RuleSourceReference(RuleSourceResolver.DetectKind(rulesSource), rulesSource, overridePath); } var layout = new RepositoryDiscovery().Resolve(repoRoot, config); @@ -85,6 +90,7 @@ public static CliRepositoryContext Resolve( config = WithRules(config, [globalPath]); layout = new RepositoryDiscovery().Resolve(repoRoot, config); provenance = RulesSourceProvenance.GlobalSettings; + explicitSource = new RuleSourceReference(globalSettings.Kind, globalSettings.Location, globalPath); } } @@ -92,6 +98,11 @@ public static CliRepositoryContext Resolve( "Resolved rules source: provenance={Provenance}, repoRoot={RepoRoot}, rulesPaths={RulesPathCount}", provenance, repoRoot, layout.RulesPaths.Count); + // Repo-config and default rules are plain local directories; an explicit source is the one entry above. + IReadOnlyList ruleSources = explicitSource is not null + ? [explicitSource] + : layout.RulesPaths.Select(p => new RuleSourceReference(RuleSourceKind.Directory, p, p)).ToList(); + return new CliRepositoryContext { RepoRoot = repoRoot, @@ -99,7 +110,8 @@ public static CliRepositoryContext Resolve( RulesProvenance = provenance, ConfigFilePath = configFilePath, GlobalSettings = globalSettings, - LoggerFactory = loggerFactory + LoggerFactory = loggerFactory, + RuleSources = ruleSources }; } diff --git a/src/CodeGuard.Cli/Support/GitRuleSourceSync.cs b/src/CodeGuard.Cli/Support/GitRuleSourceSync.cs index 19b894c..df2e31d 100644 --- a/src/CodeGuard.Cli/Support/GitRuleSourceSync.cs +++ b/src/CodeGuard.Cli/Support/GitRuleSourceSync.cs @@ -107,7 +107,7 @@ private static void Run(IReadOnlyList args, string? workingDirectory, IL } } - private static (int ExitCode, string Stdout, string Stderr) RunCore(IReadOnlyList args, string? workingDirectory) + internal static (int ExitCode, string Stdout, string Stderr) RunCore(IReadOnlyList args, string? workingDirectory) { var startInfo = new ProcessStartInfo("git") { diff --git a/src/CodeGuard.Cli/Support/RepositoryUrlNormalizer.cs b/src/CodeGuard.Cli/Support/RepositoryUrlNormalizer.cs new file mode 100644 index 0000000..0eeaeb7 --- /dev/null +++ b/src/CodeGuard.Cli/Support/RepositoryUrlNormalizer.cs @@ -0,0 +1,56 @@ +using System.Text.RegularExpressions; + +namespace CodeGuard.Cli.Support; + +/// +/// Turns a git remote (URL or scp-style user@host:path) into a stable identity that is safe to +/// write into a report: userinfo (user:token@) is always removed, query/fragment dropped, a +/// trailing / and .git stripped, and scp-style remotes rewritten as ssh://host/path. +/// Credentials must never reach a report, so this runs on every remote before it is stored. +/// +public static partial class RepositoryUrlNormalizer +{ + // Authority ends at the last '@' before the first '/', so a password containing '@' is still removed. + [GeneratedRegex(@"^(?[a-zA-Z][a-zA-Z0-9+.\-]*://)[^/]*@")] + private static partial Regex UserInfo(); + + // Requires a host of 2+ characters so Windows drive letters (C:\repo) are not mistaken for hosts. + [GeneratedRegex(@"^(?:[^@/\s]+@)?(?[^:/\s]{2,}):(?.+)$")] + private static partial Regex ScpLike(); + + public static string? Normalize(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + var value = raw.Trim(); + + if (value.Contains("://", StringComparison.Ordinal)) + { + value = UserInfo().Replace(value, "${scheme}"); + var cut = value.IndexOfAny(['?', '#']); + if (cut >= 0) + { + value = value[..cut]; + } + } + else + { + var match = ScpLike().Match(value); + if (match.Success) + { + value = $"ssh://{match.Groups["host"].Value}/{match.Groups["path"].Value.TrimStart('/')}"; + } + } + + value = value.TrimEnd('/'); + if (value.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + { + value = value[..^4].TrimEnd('/'); + } + + return value.Length == 0 ? null : value; + } +} diff --git a/src/CodeGuard.Cli/Support/RulesSourceProvenance.cs b/src/CodeGuard.Cli/Support/RulesSourceProvenance.cs index 6aa8ffc..695d4ac 100644 --- a/src/CodeGuard.Cli/Support/RulesSourceProvenance.cs +++ b/src/CodeGuard.Cli/Support/RulesSourceProvenance.cs @@ -1,5 +1,13 @@ +using CodeGuard.Configuration.GlobalConfig; + namespace CodeGuard.Cli.Support; +/// +/// One configured rules location as the CLI resolved it: the source as the user wrote it (a git URL +/// or directory), and the local directory the rules were actually loaded from (the git cache for git). +/// +public sealed record RuleSourceReference(RuleSourceKind Kind, string Location, string LocalPath); + /// Which tier of 's precedence chain produced the rules paths. public enum RulesSourceProvenance { diff --git a/src/CodeGuard.Cli/Support/ValidationProvenanceGatherer.cs b/src/CodeGuard.Cli/Support/ValidationProvenanceGatherer.cs new file mode 100644 index 0000000..15b955d --- /dev/null +++ b/src/CodeGuard.Cli/Support/ValidationProvenanceGatherer.cs @@ -0,0 +1,102 @@ +using CodeGuard.Configuration.GlobalConfig; +using CodeGuard.Reporting.Provenance; + +namespace CodeGuard.Cli.Support; + +/// Runs `git` for provenance. Returns null on any failure so provenance can never fail a validation. +public interface IGitCommandRunner +{ + /// Trimmed stdout, or null when git is missing, the directory is not a repository, or the command fails. + string? TryRun(string workingDirectory, params string[] args); +} + +/// Shells out to the system git via the same process helper uses. +public sealed class ProcessGitCommandRunner : IGitCommandRunner +{ + public string? TryRun(string workingDirectory, params string[] args) + { + try + { + var (exitCode, stdout, _) = GitRuleSourceSync.RunCore(args, workingDirectory); + return exitCode == 0 ? stdout.Trim() : null; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Covers git not being installed (Win32Exception) and a missing working directory. + return null; + } + } +} + +/// +/// Builds the for a run. Each field comes from the CI environment +/// when a known CI variable is set, and otherwise from git in the repository root, field by field. +/// Environment and git access are injected so the precedence rules can be unit-tested. +/// +public sealed class ValidationProvenanceGatherer +{ + private readonly IGitCommandRunner _git; + private readonly Func _environment; + + public ValidationProvenanceGatherer(IGitCommandRunner git, Func environment) + { + _git = git; + _environment = environment; + } + + public static ValidationProvenanceGatherer Default { get; } = + new(new ProcessGitCommandRunner(), name => Environment.GetEnvironmentVariable(name)); + + public ValidationProvenance Gather(string repoRoot, IReadOnlyList ruleSources) + { + var repository = RepositoryUrlNormalizer.Normalize(FirstNonEmpty( + GitHubRepositoryUrl(), Env("CI_PROJECT_URL"), Env("BUILD_REPOSITORY_URI"))); + var commit = FirstNonEmpty(Env("GITHUB_SHA"), Env("CI_COMMIT_SHA"), Env("BUILD_SOURCEVERSION")); + // GITHUB_HEAD_REF is the source branch of a pull request; GITHUB_REF_NAME would be "N/merge" there. + var branch = FirstNonEmpty( + Env("GITHUB_HEAD_REF"), Env("GITHUB_REF_NAME"), Env("CI_COMMIT_REF_NAME"), Env("BUILD_SOURCEBRANCHNAME")); + + repository ??= RepositoryUrlNormalizer.Normalize(_git.TryRun(repoRoot, "config", "--get", "remote.origin.url")); + commit ??= Nullify(_git.TryRun(repoRoot, "rev-parse", "HEAD")); + if (branch is null) + { + var headName = Nullify(_git.TryRun(repoRoot, "rev-parse", "--abbrev-ref", "HEAD")); + // "HEAD" means detached; there is no branch to report. + branch = headName is "HEAD" ? null : headName; + } + + return new ValidationProvenance( + Repository: repository, + Commit: commit, + Branch: branch, + RuleSources: ruleSources.Select(ToRuleSetSource).ToList()); + } + + private RuleSetSource ToRuleSetSource(RuleSourceReference source) => source.Kind switch + { + // The cache is a clone, so its HEAD pins the exact rules used. The location is the remote URL, not the cache path. + RuleSourceKind.Git => new RuleSetSource( + "git", + RepositoryUrlNormalizer.Normalize(source.Location) ?? source.Location, + Nullify(_git.TryRun(source.LocalPath, "rev-parse", "HEAD"))), + _ => new RuleSetSource("directory", source.LocalPath, null) + }; + + private string? GitHubRepositoryUrl() + { + var repository = Env("GITHUB_REPOSITORY"); + if (repository is null) + { + return null; + } + + var server = (Env("GITHUB_SERVER_URL") ?? "https://github.com").TrimEnd('/'); + return $"{server}/{repository}"; + } + + private string? Env(string name) => Nullify(_environment(name)); + + private static string? FirstNonEmpty(params string?[] values) => values.FirstOrDefault(v => v is not null); + + private static string? Nullify(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/src/CodeGuard.Reporting/Contracts/ValidationReportMapper.cs b/src/CodeGuard.Reporting/Contracts/ValidationReportMapper.cs index 8729292..ed74335 100644 --- a/src/CodeGuard.Reporting/Contracts/ValidationReportMapper.cs +++ b/src/CodeGuard.Reporting/Contracts/ValidationReportMapper.cs @@ -1,5 +1,6 @@ using System.Reflection; using CodeGuard.Core.Results; +using CodeGuard.Reporting.Provenance; using CodeGuard.RuleModel.Rules; namespace CodeGuard.Reporting.Contracts; @@ -9,7 +10,9 @@ public static class ValidationReportMapper { public const string SchemaVersion = "1.0"; - public static ValidationReportV1 ToV1(ValidationResult result, TimeSpan duration, string toolVersion) + /// Repository and rule-source provenance, when the caller gathered it. Null leaves those fields out. + public static ValidationReportV1 ToV1( + ValidationResult result, TimeSpan duration, string toolVersion, ValidationProvenance? provenance = null) { var violations = result.Violations.Select(MapViolation).ToList(); @@ -34,6 +37,12 @@ public static ValidationReportV1 ToV1(ValidationResult result, TimeSpan duration .ToList(), AnalysisWarnings: result.AnalysisWarnings .Select(w => new AnalysisWarningV1(w.Code, w.Message, w.Project, w.FilePath)) + .ToList(), + Repository: provenance?.Repository, + Commit: provenance?.Commit, + Branch: provenance?.Branch, + RuleSources: provenance?.RuleSources + .Select(r => new RuleSetSourceV1(r.Kind, r.Location, r.Commit)) .ToList()); } diff --git a/src/CodeGuard.Reporting/Contracts/ValidationReportV1.cs b/src/CodeGuard.Reporting/Contracts/ValidationReportV1.cs index 95cc26b..e7442aa 100644 --- a/src/CodeGuard.Reporting/Contracts/ValidationReportV1.cs +++ b/src/CodeGuard.Reporting/Contracts/ValidationReportV1.cs @@ -14,7 +14,11 @@ public sealed record ValidationReportV1( ValidationReportMetricsV1 Metrics, IReadOnlyList Violations, IReadOnlyList EvaluationErrors, - IReadOnlyList AnalysisWarnings); + IReadOnlyList AnalysisWarnings, + string? Repository = null, + string? Commit = null, + string? Branch = null, + IReadOnlyList? RuleSources = null); public sealed record ValidationReportMetricsV1( int RulesEvaluated, @@ -55,3 +59,5 @@ public sealed record AnalysisWarningV1( string Message, string? Project, string? FilePath); + +public sealed record RuleSetSourceV1(string Kind, string Location, string? Commit); diff --git a/src/CodeGuard.Reporting/Json/JsonViolationReporter.cs b/src/CodeGuard.Reporting/Json/JsonViolationReporter.cs index 8443d65..7b797cb 100644 --- a/src/CodeGuard.Reporting/Json/JsonViolationReporter.cs +++ b/src/CodeGuard.Reporting/Json/JsonViolationReporter.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization; using CodeGuard.Core.Results; using CodeGuard.Reporting.Contracts; +using CodeGuard.Reporting.Provenance; namespace CodeGuard.Reporting.Json; @@ -20,20 +21,23 @@ public sealed class JsonViolationReporter : IViolationReporter private readonly string _toolVersion; private readonly TimeSpan _duration; + private readonly ValidationProvenance? _provenance; /// Reported as toolVersion. Defaults to this assembly's version. /// Reported as durationMs. Defaults to zero, i.e. not measured. - public JsonViolationReporter(string? toolVersion = null, TimeSpan duration = default) + /// Reported as repository/commit/branch/ruleSources. Omitted when null. + public JsonViolationReporter(string? toolVersion = null, TimeSpan duration = default, ValidationProvenance? provenance = null) { _toolVersion = toolVersion ?? ValidationReportMapper.ResolveToolVersion(typeof(JsonViolationReporter).Assembly); _duration = duration; + _provenance = provenance; } public string Format => "json"; public async Task WriteAsync(ValidationResult result, TextWriter writer, CancellationToken ct = default) { - var report = ValidationReportMapper.ToV1(result, _duration, _toolVersion); + var report = ValidationReportMapper.ToV1(result, _duration, _toolVersion, _provenance); var json = JsonSerializer.Serialize(report, Options); await writer.WriteLineAsync(json.AsMemory(), ct); } diff --git a/src/CodeGuard.Reporting/Provenance/ValidationProvenance.cs b/src/CodeGuard.Reporting/Provenance/ValidationProvenance.cs new file mode 100644 index 0000000..79551f3 --- /dev/null +++ b/src/CodeGuard.Reporting/Provenance/ValidationProvenance.cs @@ -0,0 +1,21 @@ +namespace CodeGuard.Reporting.Provenance; + +/// +/// Where a validation run came from: the analysed repository and the rule sets it was evaluated +/// against. Gathered by the CLI (git and CI environment) and handed to the reporters, which never +/// shell out themselves. Every field is optional; unknown values are null and omitted from output. +/// +/// Normalised remote URL, with any credentials or userinfo removed. +/// Commit SHA of the analysed checkout. +/// Branch name of the analysed checkout (null when detached and not known from CI). +/// Rule sets used, one per configured rules location. +public sealed record ValidationProvenance( + string? Repository, + string? Commit, + string? Branch, + IReadOnlyList RuleSources); + +/// "git" or "directory". +/// Normalised git URL (credentials removed) or local directory path. +/// HEAD of the rules cache for git sources; null for directories. +public sealed record RuleSetSource(string Kind, string Location, string? Commit); diff --git a/src/CodeGuard.Reporting/Sarif/SarifViolationReporter.cs b/src/CodeGuard.Reporting/Sarif/SarifViolationReporter.cs index 11032d7..c26fb43 100644 --- a/src/CodeGuard.Reporting/Sarif/SarifViolationReporter.cs +++ b/src/CodeGuard.Reporting/Sarif/SarifViolationReporter.cs @@ -1,11 +1,27 @@ using Microsoft.CodeAnalysis.Sarif; using CodeGuard.Core.Results; +using CodeGuard.Reporting.Contracts; +using CodeGuard.Reporting.Provenance; using CodeGuard.RuleModel.Rules; namespace CodeGuard.Reporting.Sarif; public sealed class SarifViolationReporter : IViolationReporter { + private readonly ValidationProvenance? _provenance; + private readonly string _toolVersion; + + /// + /// Populates run.versionControlProvenance (when a repository URI is known) and the run property + /// bag's ruleSources. Null leaves both out. + /// + /// Reported as tool.driver.version. Defaults to this assembly's version. + public SarifViolationReporter(ValidationProvenance? provenance = null, string? toolVersion = null) + { + _provenance = provenance; + _toolVersion = toolVersion ?? ValidationReportMapper.ResolveToolVersion(typeof(SarifViolationReporter).Assembly); + } + public string Format => "sarif"; public async Task WriteAsync(ValidationResult result, TextWriter writer, CancellationToken ct = default) @@ -35,6 +51,7 @@ public async Task WriteAsync(ValidationResult result, TextWriter writer, Cancell Driver = new ToolComponent { Name = "codeguard", + Version = _toolVersion, Rules = rules } }, @@ -51,6 +68,8 @@ public async Task WriteAsync(ValidationResult result, TextWriter writer, Cancell ] }; + AddProvenance(run, _provenance); + var log = new SarifLog { Version = SarifVersion.Current, @@ -67,6 +86,41 @@ public async Task WriteAsync(ValidationResult result, TextWriter writer, Cancell await writer.WriteLineAsync(json.AsMemory(), ct); } + /// + /// Repository identity goes in versionControlProvenance, which SARIF requires to carry a + /// repositoryUri, so it is only written when the repository is known and parses as a URI. + /// Rule-set provenance has no SARIF home, so it goes in the run property bag. + /// + private static void AddProvenance(Run run, ValidationProvenance? provenance) + { + if (provenance is null) + { + return; + } + + if (provenance.Repository is not null && Uri.TryCreate(provenance.Repository, UriKind.Absolute, out var repositoryUri)) + { + run.VersionControlProvenance = + [ + new VersionControlDetails + { + RepositoryUri = repositoryUri, + RevisionId = provenance.Commit, + Branch = provenance.Branch + } + ]; + } + + run.SetProperty("ruleSources", provenance.RuleSources + .Select(r => new Dictionary + { + ["kind"] = r.Kind, + ["location"] = r.Location, + ["commit"] = r.Commit + }) + .ToList()); + } + private static Result ToSarifResult(Violation violation) { var result = new Result diff --git a/src/CodeGuard.Reporting/Schemas/validation-report.v1.schema.json b/src/CodeGuard.Reporting/Schemas/validation-report.v1.schema.json index c102a4d..0454443 100644 --- a/src/CodeGuard.Reporting/Schemas/validation-report.v1.schema.json +++ b/src/CodeGuard.Reporting/Schemas/validation-report.v1.schema.json @@ -53,6 +53,26 @@ "analysisWarnings": { "type": "array", "items": { "$ref": "#/$defs/analysisWarning" } + }, + "repository": { + "type": "string", + "minLength": 1, + "description": "Normalised remote URL of the analysed repository, with any credentials or userinfo removed. Omitted when unknown." + }, + "commit": { + "type": "string", + "minLength": 1, + "description": "Commit SHA of the analysed checkout. Omitted when unknown." + }, + "branch": { + "type": "string", + "minLength": 1, + "description": "Branch of the analysed checkout. Omitted when unknown (for example a detached HEAD outside CI)." + }, + "ruleSources": { + "type": "array", + "items": { "$ref": "#/$defs/ruleSetSource" }, + "description": "Rule sets the run was evaluated against. Omitted when the producer did not record rule-source provenance." } }, "$defs": { @@ -143,6 +163,16 @@ "project": { "type": "string" }, "filePath": { "type": "string" } } + }, + "ruleSetSource": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "location"], + "properties": { + "kind": { "type": "string", "enum": ["git", "directory"] }, + "location": { "type": "string", "minLength": 1, "description": "Normalised git URL (credentials removed) or local directory path." }, + "commit": { "type": "string", "minLength": 1, "description": "HEAD of the rules cache for git sources. Omitted for directories and when unknown." } + } } } } diff --git a/tests/CodeGuard.Cli.Tests/RepositoryUrlNormalizerTests.cs b/tests/CodeGuard.Cli.Tests/RepositoryUrlNormalizerTests.cs new file mode 100644 index 0000000..ce78dfa --- /dev/null +++ b/tests/CodeGuard.Cli.Tests/RepositoryUrlNormalizerTests.cs @@ -0,0 +1,34 @@ +using CodeGuard.Cli.Support; + +namespace CodeGuard.Cli.Tests; + +public class RepositoryUrlNormalizerTests +{ + [Theory] + [InlineData("https://github.com/acme/app.git", "https://github.com/acme/app")] + [InlineData("https://github.com/acme/app/", "https://github.com/acme/app")] + [InlineData("https://github.com/acme/app", "https://github.com/acme/app")] + [InlineData("https://user:ghp_secret@github.com/acme/app.git", "https://github.com/acme/app")] + [InlineData("https://x-access-token@github.com/acme/app", "https://github.com/acme/app")] + [InlineData("https://user:p@ss@host.example.com/org/repo.git", "https://host.example.com/org/repo")] + [InlineData("https://host.example.com/org/repo.git?token=abc#frag", "https://host.example.com/org/repo")] + [InlineData("ssh://git@host.example.com:2222/org/repo.git", "ssh://host.example.com:2222/org/repo")] + [InlineData("git@github.com:acme/app.git", "ssh://github.com/acme/app")] + [InlineData("github.com:acme/app", "ssh://github.com/acme/app")] + [InlineData(" https://github.com/acme/app.git ", "https://github.com/acme/app")] + [InlineData("/srv/git/repo.git", "/srv/git/repo")] + [InlineData("C:\\repos\\app", "C:\\repos\\app")] + public void Normalize_RemovesCredentialsAndDecoration(string raw, string expected) + { + Assert.Equal(expected, RepositoryUrlNormalizer.Normalize(raw)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Normalize_EmptyInput_ReturnsNull(string? raw) + { + Assert.Null(RepositoryUrlNormalizer.Normalize(raw)); + } +} diff --git a/tests/CodeGuard.Cli.Tests/ValidationProvenanceGathererTests.cs b/tests/CodeGuard.Cli.Tests/ValidationProvenanceGathererTests.cs new file mode 100644 index 0000000..ae15caf --- /dev/null +++ b/tests/CodeGuard.Cli.Tests/ValidationProvenanceGathererTests.cs @@ -0,0 +1,211 @@ +using CodeGuard.Cli.Support; +using CodeGuard.Configuration.GlobalConfig; + +namespace CodeGuard.Cli.Tests; + +public class ValidationProvenanceGathererTests +{ + private const string RepoRoot = "/work/repo"; + private const string RulesCache = "/cache/rules"; + + private static readonly string[] OriginUrl = ["config", "--get", "remote.origin.url"]; + private static readonly string[] HeadSha = ["rev-parse", "HEAD"]; + private static readonly string[] HeadBranch = ["rev-parse", "--abbrev-ref", "HEAD"]; + + [Fact] + public void Gather_OutsideGit_ReturnsNullsAndDirectorySource() + { + var gatherer = new ValidationProvenanceGatherer(new FakeGit(), _ => null); + + var result = gatherer.Gather(RepoRoot, [new RuleSourceReference(RuleSourceKind.Directory, "/r/rules", "/r/rules")]); + + Assert.Null(result.Repository); + Assert.Null(result.Commit); + Assert.Null(result.Branch); + var source = Assert.Single(result.RuleSources); + Assert.Equal("directory", source.Kind); + Assert.Equal("/r/rules", source.Location); + Assert.Null(source.Commit); + } + + [Fact] + public void Gather_FromGit_ReadsRemoteCommitAndBranch() + { + var git = new FakeGit + { + [(RepoRoot, OriginUrl)] = "git@github.com:acme/app.git\n", + [(RepoRoot, HeadSha)] = "abc123\n", + [(RepoRoot, HeadBranch)] = "feature/x\n" + }; + + var result = new ValidationProvenanceGatherer(git, _ => null).Gather(RepoRoot, []); + + Assert.Equal("ssh://github.com/acme/app", result.Repository); + Assert.Equal("abc123", result.Commit); + Assert.Equal("feature/x", result.Branch); + } + + [Fact] + public void Gather_DetachedHeadWithoutCi_ReportsNoBranch() + { + var git = new FakeGit { [(RepoRoot, HeadBranch)] = "HEAD\n" }; + + var result = new ValidationProvenanceGatherer(git, _ => null).Gather(RepoRoot, []); + + Assert.Null(result.Branch); + } + + [Fact] + public void Gather_GitHubActions_EnvironmentOverridesGit() + { + var git = new FakeGit + { + [(RepoRoot, OriginUrl)] = "https://git.local/old.git", + [(RepoRoot, HeadSha)] = "localsha", + [(RepoRoot, HeadBranch)] = "HEAD" + }; + var env = Env( + ("GITHUB_REPOSITORY", "acme/app"), + ("GITHUB_SERVER_URL", "https://github.com/"), + ("GITHUB_SHA", "ci-sha"), + ("GITHUB_REF_NAME", "42/merge"), + ("GITHUB_HEAD_REF", "fix/thing")); + + var result = new ValidationProvenanceGatherer(git, env).Gather(RepoRoot, []); + + Assert.Equal("https://github.com/acme/app", result.Repository); + Assert.Equal("ci-sha", result.Commit); + Assert.Equal("fix/thing", result.Branch); + } + + [Fact] + public void Gather_GitHubActions_FallsBackToRefNameWithoutHeadRef() + { + var env = Env(("GITHUB_REPOSITORY", "acme/app"), ("GITHUB_REF_NAME", "main")); + + var result = new ValidationProvenanceGatherer(new FakeGit(), env).Gather(RepoRoot, []); + + Assert.Equal("https://github.com/acme/app", result.Repository); + Assert.Equal("main", result.Branch); + } + + [Fact] + public void Gather_GitLabCi_UsesProjectUrlCommitAndRefName() + { + var env = Env( + ("CI_PROJECT_URL", "https://gitlab.example.com/group/project"), + ("CI_COMMIT_SHA", "gl-sha"), + ("CI_COMMIT_REF_NAME", "release")); + + var result = new ValidationProvenanceGatherer(new FakeGit(), env).Gather(RepoRoot, []); + + Assert.Equal("https://gitlab.example.com/group/project", result.Repository); + Assert.Equal("gl-sha", result.Commit); + Assert.Equal("release", result.Branch); + } + + [Fact] + public void Gather_AzurePipelines_UsesBuildVariables() + { + var env = Env( + ("BUILD_REPOSITORY_URI", "https://dev.azure.com/org/project/_git/repo"), + ("BUILD_SOURCEVERSION", "az-sha"), + ("BUILD_SOURCEBRANCHNAME", "develop")); + + var result = new ValidationProvenanceGatherer(new FakeGit(), env).Gather(RepoRoot, []); + + Assert.Equal("https://dev.azure.com/org/project/_git/repo", result.Repository); + Assert.Equal("az-sha", result.Commit); + Assert.Equal("develop", result.Branch); + } + + [Fact] + public void Gather_CiUrlWithCredentials_IsStripped() + { + var env = Env(("CI_PROJECT_URL", "https://oauth2:glpat-secret@gitlab.example.com/group/project.git")); + + var result = new ValidationProvenanceGatherer(new FakeGit(), env).Gather(RepoRoot, []); + + Assert.Equal("https://gitlab.example.com/group/project", result.Repository); + Assert.DoesNotContain("secret", result.Repository, StringComparison.Ordinal); + } + + [Fact] + public void Gather_GitRemoteWithCredentials_IsStripped() + { + var git = new FakeGit { [(RepoRoot, OriginUrl)] = "https://user:tok@dev.example.com/org/repo.git" }; + + var result = new ValidationProvenanceGatherer(git, _ => null).Gather(RepoRoot, []); + + Assert.Equal("https://dev.example.com/org/repo", result.Repository); + } + + [Fact] + public void Gather_GitRuleSource_ReportsRemoteAndCacheCommit() + { + var git = new FakeGit { [(RulesCache, HeadSha)] = "rules-sha" }; + var source = new RuleSourceReference(RuleSourceKind.Git, "https://u:p@rules.example.com/acme/rules.git", RulesCache); + + var result = new ValidationProvenanceGatherer(git, _ => null).Gather(RepoRoot, [source]); + + var rule = Assert.Single(result.RuleSources); + Assert.Equal("git", rule.Kind); + Assert.Equal("https://rules.example.com/acme/rules", rule.Location); + Assert.Equal("rules-sha", rule.Commit); + } + + [Fact] + public void Gather_GitRuleSourceWithoutGit_KeepsLocationWithNullCommit() + { + var source = new RuleSourceReference(RuleSourceKind.Git, "https://rules.example.com/acme/rules.git", RulesCache); + + var result = new ValidationProvenanceGatherer(new FakeGit(), _ => null).Gather(RepoRoot, [source]); + + var rule = Assert.Single(result.RuleSources); + Assert.Equal("https://rules.example.com/acme/rules", rule.Location); + Assert.Null(rule.Commit); + } + + [Fact] + public void ProcessGitCommandRunner_NonRepositoryDirectory_ReturnsNull() + { + var notARepo = Path.Combine(Path.GetTempPath(), "codeguard-provenance-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(notARepo); + try + { + Assert.Null(new ProcessGitCommandRunner().TryRun(notARepo, "rev-parse", "HEAD")); + } + finally + { + Directory.Delete(notARepo, recursive: true); + } + } + + [Fact] + public void ProcessGitCommandRunner_MissingDirectory_ReturnsNull() + { + var missing = Path.Combine(Path.GetTempPath(), "codeguard-missing-" + Guid.NewGuid().ToString("N")); + + Assert.Null(new ProcessGitCommandRunner().TryRun(missing, "rev-parse", "HEAD")); + } + + private static Func Env(params (string Name, string Value)[] values) + { + var map = values.ToDictionary(v => v.Name, v => v.Value, StringComparer.Ordinal); + return name => map.GetValueOrDefault(name); + } + + /// Keyed by (working directory, git arguments). Unmapped calls behave as "git fails". + private sealed class FakeGit : IGitCommandRunner + { + private readonly Dictionary<(string Dir, string Args), string> _responses = new(); + + public string? this[(string Dir, string[] Args) key] + { + set => _responses[(key.Dir, string.Join(' ', key.Args))] = value!; + } + + public string? TryRun(string workingDirectory, params string[] args) => + _responses.TryGetValue((workingDirectory, string.Join(' ', args)), out var output) ? output : null; + } +} diff --git a/tests/CodeGuard.Core.Tests/Reporting/SarifViolationReporterTests.cs b/tests/CodeGuard.Core.Tests/Reporting/SarifViolationReporterTests.cs index 05e4392..30439a5 100644 --- a/tests/CodeGuard.Core.Tests/Reporting/SarifViolationReporterTests.cs +++ b/tests/CodeGuard.Core.Tests/Reporting/SarifViolationReporterTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using CodeGuard.Core.Results; +using CodeGuard.Reporting.Provenance; using CodeGuard.Reporting.Sarif; using CodeGuard.RuleModel.Rules; @@ -7,6 +8,70 @@ namespace CodeGuard.Core.Tests.Reporting; public class SarifViolationReporterTests { + private static ValidationResult EmptyPassingResult() => new( + ValidationStatus.Passed, RulesEvaluated: 0, RulesPassed: 0, RulesFailed: 0, RulesErrored: 0, + Violations: [], EvaluationErrors: [], EvaluatedAtUtc: DateTimeOffset.UtcNow); + + [Fact] + public async Task WriteAsync_WithProvenance_WritesVersionControlDetailsAndRuleSources() + { + var provenance = new ValidationProvenance( + Repository: "https://github.com/acme/app", + Commit: "abc123", + Branch: "main", + RuleSources: [new RuleSetSource("git", "https://rules.example.com/acme/rules", "rules-sha")]); + + var writer = new StringWriter(); + await new SarifViolationReporter(provenance, "1.2.3").WriteAsync(EmptyPassingResult(), writer); + + using var document = JsonDocument.Parse(writer.ToString()); + var run = document.RootElement.GetProperty("runs")[0]; + + var driver = run.GetProperty("tool").GetProperty("driver"); + Assert.Equal("1.2.3", driver.GetProperty("version").GetString()); + + var vcs = run.GetProperty("versionControlProvenance")[0]; + Assert.Equal("https://github.com/acme/app", vcs.GetProperty("repositoryUri").GetString()); + Assert.Equal("abc123", vcs.GetProperty("revisionId").GetString()); + Assert.Equal("main", vcs.GetProperty("branch").GetString()); + + var ruleSource = run.GetProperty("properties").GetProperty("ruleSources")[0]; + Assert.Equal("git", ruleSource.GetProperty("kind").GetString()); + Assert.Equal("https://rules.example.com/acme/rules", ruleSource.GetProperty("location").GetString()); + Assert.Equal("rules-sha", ruleSource.GetProperty("commit").GetString()); + } + + [Fact] + public async Task WriteAsync_WithoutProvenance_OmitsVersionControlDetails() + { + var writer = new StringWriter(); + await new SarifViolationReporter().WriteAsync(EmptyPassingResult(), writer); + + using var document = JsonDocument.Parse(writer.ToString()); + var run = document.RootElement.GetProperty("runs")[0]; + + Assert.False(run.TryGetProperty("versionControlProvenance", out _)); + Assert.False(run.TryGetProperty("properties", out _)); + Assert.False(string.IsNullOrEmpty(run.GetProperty("tool").GetProperty("driver").GetProperty("version").GetString())); + } + + [Fact] + public async Task WriteAsync_WithoutRepositoryUri_OmitsVersionControlDetailsButKeepsRuleSources() + { + var provenance = new ValidationProvenance( + Repository: null, Commit: "abc123", Branch: null, + RuleSources: [new RuleSetSource("directory", "/repo/rules", null)]); + + var writer = new StringWriter(); + await new SarifViolationReporter(provenance, "1.2.3").WriteAsync(EmptyPassingResult(), writer); + + using var document = JsonDocument.Parse(writer.ToString()); + var run = document.RootElement.GetProperty("runs")[0]; + + Assert.False(run.TryGetProperty("versionControlProvenance", out _)); + Assert.Equal("/repo/rules", run.GetProperty("properties").GetProperty("ruleSources")[0].GetProperty("location").GetString()); + } + [Fact] public async Task WriteAsync_ProducesValidSarifLog() { diff --git a/tests/CodeGuard.Core.Tests/Reporting/ValidationReportSchemaTests.cs b/tests/CodeGuard.Core.Tests/Reporting/ValidationReportSchemaTests.cs index 6a5c53e..efbb2c1 100644 --- a/tests/CodeGuard.Core.Tests/Reporting/ValidationReportSchemaTests.cs +++ b/tests/CodeGuard.Core.Tests/Reporting/ValidationReportSchemaTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using CodeGuard.Core.Results; using CodeGuard.Reporting.Json; +using CodeGuard.Reporting.Provenance; using CodeGuard.RuleModel.Rules; using Json.Schema; @@ -103,6 +104,82 @@ public void Schema_RejectsUnknownTopLevelProperty() Assert.False(results.IsValid); } + [Fact] + public async Task Output_WithProvenance_ConformsToSchemaAndCarriesFields() + { + var result = new ValidationResult( + ValidationStatus.Passed, RulesEvaluated: 1, RulesPassed: 1, RulesFailed: 0, RulesErrored: 0, + Violations: [], EvaluationErrors: [], EvaluatedAtUtc: DateTimeOffset.UtcNow); + var provenance = new ValidationProvenance( + Repository: "https://github.com/acme/app", + Commit: "abc123", + Branch: "main", + RuleSources: + [ + new RuleSetSource("git", "https://rules.example.com/acme/rules", "rules-sha"), + new RuleSetSource("directory", "/repo/rules", null) + ]); + + var writer = new StringWriter(); + await new JsonViolationReporter("1.2.3", TimeSpan.Zero, provenance).WriteAsync(result, writer); + var output = writer.ToString(); + + AssertConforms(output); + using var document = JsonDocument.Parse(output); + var root = document.RootElement; + Assert.Equal("https://github.com/acme/app", root.GetProperty("repository").GetString()); + Assert.Equal("abc123", root.GetProperty("commit").GetString()); + Assert.Equal("main", root.GetProperty("branch").GetString()); + var sources = root.GetProperty("ruleSources"); + Assert.Equal("git", sources[0].GetProperty("kind").GetString()); + Assert.Equal("rules-sha", sources[0].GetProperty("commit").GetString()); + Assert.False(sources[1].TryGetProperty("commit", out _)); + } + + [Fact] + public async Task Output_WithUnknownProvenance_OmitsFields() + { + var result = new ValidationResult( + ValidationStatus.Passed, RulesEvaluated: 0, RulesPassed: 0, RulesFailed: 0, RulesErrored: 0, + Violations: [], EvaluationErrors: [], EvaluatedAtUtc: DateTimeOffset.UtcNow); + var provenance = new ValidationProvenance(null, null, null, []); + + var writer = new StringWriter(); + await new JsonViolationReporter("1.2.3", TimeSpan.Zero, provenance).WriteAsync(result, writer); + var output = writer.ToString(); + + AssertConforms(output); + using var document = JsonDocument.Parse(output); + Assert.False(document.RootElement.TryGetProperty("repository", out _)); + Assert.False(document.RootElement.TryGetProperty("commit", out _)); + Assert.False(document.RootElement.TryGetProperty("branch", out _)); + Assert.Empty(document.RootElement.GetProperty("ruleSources").EnumerateArray()); + } + + [Fact] + public void Schema_RejectsCredentialFieldsInRuleSource() + { + const string document = """ + { + "schemaVersion": "1.0", "toolVersion": "1.0.0", "status": "passed", + "evaluatedAtUtc": "2026-01-01T00:00:00+00:00", "durationMs": 0, + "metrics": { + "rulesEvaluated": 0, "rulesPassed": 0, "rulesFailed": 0, "rulesErrored": 0, + "violationCount": 0, + "violationsBySeverity": { "info": 0, "warning": 0, "error": 0, "critical": 0 }, + "evaluationErrorCount": 0, "analysisWarningCount": 0 + }, + "violations": [], "evaluationErrors": [], "analysisWarnings": [], + "ruleSources": [ { "kind": "git", "location": "https://u:p@host/x", "token": "p" } ] + } + """; + + using var parsed = JsonDocument.Parse(document); + var results = ReportSchema.Value.Evaluate(parsed.RootElement, new EvaluationOptions { OutputFormat = OutputFormat.List }); + + Assert.False(results.IsValid); + } + private static async Task WriteAsync(ValidationResult result, TimeSpan duration) { var writer = new StringWriter();