Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/architecture/IMPLEMENTATION_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
30 changes: 26 additions & 4 deletions docs/architecture/RESULT_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
}
```

Expand Down Expand Up @@ -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.
23 changes: 15 additions & 8 deletions src/CodeGuard.Cli/Commands/ValidateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,7 +20,7 @@

public static class ValidateCommand
{
public static Command Build()

Check warning on line 23 in src/CodeGuard.Cli/Commands/ValidateCommand.cs

View workflow job for this annotation

GitHub Actions / Build And Test

Refactor this method to reduce its Cognitive Complexity from 29 to the 15 allowed.
{
var pathOption = CommonOptions.CreatePathOption();
var configOption = CommonOptions.CreateConfigOption();
Expand Down Expand Up @@ -208,8 +209,10 @@
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");

Expand Down Expand Up @@ -259,19 +262,23 @@
RulesPassed = result.RulesEvaluated - rulesFailed - result.RulesErrored,
Status = result.EvaluationErrors.Count > 0
? ValidationStatus.PartiallyEvaluated
: filteredViolations.Count == 0 ? ValidationStatus.Passed : ValidationStatus.Failed

Check warning on line 265 in src/CodeGuard.Cli/Commands/ValidateCommand.cs

View workflow job for this annotation

GitHub Actions / Build And Test

Extract this nested ternary operation into an independent statement.
};
}

// 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
{
Expand Down
14 changes: 13 additions & 1 deletion src/CodeGuard.Cli/Support/CliRepositoryContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
public GlobalSettings? GlobalSettings { get; init; }
public ILoggerFactory? LoggerFactory { get; init; }

/// <summary>The rules locations the run is evaluated against, for result provenance.</summary>
public IReadOnlyList<RuleSourceReference> RuleSources { get; init; } = [];

/// <param name="globalSettingsRoot">
/// Overrides where tier 4 looks for a prior `setup` run's <c>settings.yml</c> and git cache
/// (default: the real OS app-data root, <see cref="GlobalSettingsPaths.ResolveRoot"/>). Exists
Expand All @@ -57,16 +60,18 @@

var provenance = rulesSource is not null
? RulesSourceProvenance.CliOverride
: File.Exists(configFilePath)

Check warning on line 63 in src/CodeGuard.Cli/Support/CliRepositoryContext.cs

View workflow job for this annotation

GitHub Actions / Build And Test

Extract this nested ternary operation into an independent statement.
? 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);
Expand All @@ -85,21 +90,28 @@
config = WithRules(config, [globalPath]);
layout = new RepositoryDiscovery().Resolve(repoRoot, config);
provenance = RulesSourceProvenance.GlobalSettings;
explicitSource = new RuleSourceReference(globalSettings.Kind, globalSettings.Location, globalPath);
}
}

logger.LogInformation(
"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<RuleSourceReference> ruleSources = explicitSource is not null
? [explicitSource]
: layout.RulesPaths.Select(p => new RuleSourceReference(RuleSourceKind.Directory, p, p)).ToList();

return new CliRepositoryContext
{
RepoRoot = repoRoot,
Layout = layout,
RulesProvenance = provenance,
ConfigFilePath = configFilePath,
GlobalSettings = globalSettings,
LoggerFactory = loggerFactory
LoggerFactory = loggerFactory,
RuleSources = ruleSources
};
}

Expand All @@ -114,7 +126,7 @@
/// <see cref="GlobalSettingsStore"/>, <see cref="RuleSourceResolver"/>, <see cref="GitRuleSourceSync"/>)
/// - anything else still propagates as a genuine bug.
/// </summary>
public static bool TryResolve(

Check warning on line 129 in src/CodeGuard.Cli/Support/CliRepositoryContext.cs

View workflow job for this annotation

GitHub Actions / Build And Test

Method has 8 parameters, which is greater than the 7 authorized.
string? path, string? configPath,
[NotNullWhen(true)] out CliRepositoryContext? context,
[NotNullWhen(false)] out string? errorMessage,
Expand Down
2 changes: 1 addition & 1 deletion src/CodeGuard.Cli/Support/GitRuleSourceSync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
return GitSyncResult.Blocked;
}

var fetchArgs = branch is null ? new[] { "fetch", "origin" } : new[] { "fetch", "origin", branch };

Check warning on line 62 in src/CodeGuard.Cli/Support/GitRuleSourceSync.cs

View workflow job for this annotation

GitHub Actions / Build And Test

Define a constant instead of using this literal 'origin' 4 times.
Run(fetchArgs, destinationDir, logger);

var upstreamRef = branch is null ? "origin/HEAD" : $"origin/{branch}";
Expand Down Expand Up @@ -107,9 +107,9 @@
}
}

private static (int ExitCode, string Stdout, string Stderr) RunCore(IReadOnlyList<string> args, string? workingDirectory)
internal static (int ExitCode, string Stdout, string Stderr) RunCore(IReadOnlyList<string> args, string? workingDirectory)
{
var startInfo = new ProcessStartInfo("git")

Check warning on line 112 in src/CodeGuard.Cli/Support/GitRuleSourceSync.cs

View workflow job for this annotation

GitHub Actions / Build And Test

Use an absolute path for this command.
{
RedirectStandardOutput = true,
RedirectStandardError = true,
Expand Down
56 changes: 56 additions & 0 deletions src/CodeGuard.Cli/Support/RepositoryUrlNormalizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Text.RegularExpressions;

namespace CodeGuard.Cli.Support;

/// <summary>
/// Turns a git remote (URL or scp-style <c>user@host:path</c>) into a stable identity that is safe to
/// write into a report: userinfo (<c>user:token@</c>) is always removed, query/fragment dropped, a
/// trailing <c>/</c> and <c>.git</c> stripped, and scp-style remotes rewritten as <c>ssh://host/path</c>.
/// Credentials must never reach a report, so this runs on every remote before it is stored.
/// </summary>
public static partial class RepositoryUrlNormalizer
{
// Authority ends at the last '@' before the first '/', so a password containing '@' is still removed.
[GeneratedRegex(@"^(?<scheme>[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]+@)?(?<host>[^:/\s]{2,}):(?<path>.+)$")]
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;
}
}
8 changes: 8 additions & 0 deletions src/CodeGuard.Cli/Support/RulesSourceProvenance.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
using CodeGuard.Configuration.GlobalConfig;

namespace CodeGuard.Cli.Support;

/// <summary>
/// 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).
/// </summary>
public sealed record RuleSourceReference(RuleSourceKind Kind, string Location, string LocalPath);

/// <summary>Which tier of <see cref="CliRepositoryContext.Resolve"/>'s precedence chain produced the rules paths.</summary>
public enum RulesSourceProvenance
{
Expand Down
102 changes: 102 additions & 0 deletions src/CodeGuard.Cli/Support/ValidationProvenanceGatherer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using CodeGuard.Configuration.GlobalConfig;
using CodeGuard.Reporting.Provenance;

namespace CodeGuard.Cli.Support;

/// <summary>Runs `git` for provenance. Returns null on any failure so provenance can never fail a validation.</summary>
public interface IGitCommandRunner
{
/// <returns>Trimmed stdout, or null when git is missing, the directory is not a repository, or the command fails.</returns>
string? TryRun(string workingDirectory, params string[] args);
}

/// <summary>Shells out to the system git via the same process helper <see cref="GitRuleSourceSync"/> uses.</summary>
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;
}
}
}

/// <summary>
/// Builds the <see cref="ValidationProvenance"/> 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.
/// </summary>
public sealed class ValidationProvenanceGatherer
{
private readonly IGitCommandRunner _git;
private readonly Func<string, string?> _environment;

public ValidationProvenanceGatherer(IGitCommandRunner git, Func<string, string?> environment)
{
_git = git;
_environment = environment;
}

public static ValidationProvenanceGatherer Default { get; } =
new(new ProcessGitCommandRunner(), name => Environment.GetEnvironmentVariable(name));

public ValidationProvenance Gather(string repoRoot, IReadOnlyList<RuleSourceReference> 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();
}
11 changes: 10 additions & 1 deletion src/CodeGuard.Reporting/Contracts/ValidationReportMapper.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Reflection;
using CodeGuard.Core.Results;
using CodeGuard.Reporting.Provenance;
using CodeGuard.RuleModel.Rules;

namespace CodeGuard.Reporting.Contracts;
Expand All @@ -9,7 +10,9 @@ public static class ValidationReportMapper
{
public const string SchemaVersion = "1.0";

public static ValidationReportV1 ToV1(ValidationResult result, TimeSpan duration, string toolVersion)
/// <param name="provenance">Repository and rule-source provenance, when the caller gathered it. Null leaves those fields out.</param>
public static ValidationReportV1 ToV1(
ValidationResult result, TimeSpan duration, string toolVersion, ValidationProvenance? provenance = null)
{
var violations = result.Violations.Select(MapViolation).ToList();

Expand All @@ -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());
}

Expand Down
Loading
Loading