Skip to content

Commit 25e7ed6

Browse files
dennisdoomenclaude
andcommitted
Add support for PackageGuard and run it in the pipeline
Adds a Fallout CLI tool wrapper for PackageGuard's analyze command, based on AnalyzeCommandSettings.cs (https://github.com/dennisdoomen/packageguard/blob/main/Src/PackageGuard/AnalyzeCommandSettings.cs), and wires it into the build pipeline as a compliance gate plus SBOM and risk reporting. Tool wrapper: - src/Fallout.Common/Tools/PackageGuard/PackageGuard.json, covering all 18 analyze settings, plus NpmPackageManager and SbomFormat enumerations for the properties restricted to a fixed set of values. - PackageGuard.Generated.cs, regenerated via ./build.ps1 GenerateTools. - A row in the supported-tools table in docs/website/03-common/08-cli-tools.md. - TestPackageGuard in tests/Fallout.Common.Specs/SettingsSpecs.cs. Build pipeline: - New PackageGuard target (build/Build.PackageGuard.cs) runs the policy-violation check on every PR (added to build.yml's required gate, alongside VerifyGeneratedTools/Test/Pack) — a license or package-policy violation now blocks the PR gate like any other check. - The SBOM (CycloneDX) and HTML + SARIF risk report are only generated on main, develop, release/*, or support/* — via GitRepository.IsOn*Branch() (including a new IsOnSupportBranch() extension), or, for the tag-triggered release workflow where HEAD is detached, because that workflow's own validate-ref job already proved the tag is reachable from a production branch. - New security-scan workflow (generated from a third [GitHubActions] attribute, using the existing IConfigureGitHubActions custom-step hook) runs on every push to those branches and uploads the SARIF report to GitHub code scanning via github/codeql-action/upload-sarif. - publish-packages-release.yml now runs PackageGuard alongside Test+Pack and attaches both the SBOM and the HTML risk report to the GitHub Release as assets. - Added .packageguard/config.json: an allowlist covering the permissive license family this repo's actual dependencies use (MIT, Apache-2.0, BSD-2/3-Clause, ISC, 0BSD, MS-PL), plus an explicit package-name allowance for FluentAssertions — its pinned 8.10.0 reports no SPDX license expression at all (a licenseFile plus a note that commercial use requires a paid Xceed license), so no license-based match would ever cover it. Mirrors the same package-name allowance PackageGuard's own repo uses on itself. Without a config file, PackageGuard's NuGet analysis throws on every run instead of defaulting permissive, which would have broken every PR the moment this became a required check. Purely additive — no breaking changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 9e09643 commit 25e7ed6

13 files changed

Lines changed: 762 additions & 15 deletions

File tree

.fallout/build.schema.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"GenerateTools",
3535
"Install",
3636
"Pack",
37+
"PackageGuard",
3738
"Publish",
3839
"References",
3940
"ReportCoverage",

.github/workflows/build.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,7 @@ jobs:
5555
global-json-file: global.json
5656
- name: 'Restore: dotnet tools'
5757
run: dotnet tool restore
58-
- name: 'Run: VerifyGeneratedTools, Test, Pack'
59-
run: dotnet fallout VerifyGeneratedTools Test Pack
58+
- name: 'Run: VerifyGeneratedTools, Test, Pack, PackageGuard'
59+
run: dotnet fallout VerifyGeneratedTools Test Pack PackageGuard
60+
env:
61+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/publish-packages-release.yml

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,22 +119,36 @@ jobs:
119119
global-json-file: global.json
120120
- name: 'Restore: dotnet tools'
121121
run: dotnet tool restore
122-
- name: 'Run: Test + Pack'
123-
run: dotnet fallout Test Pack
122+
- name: 'Run: Test + Pack + PackageGuard'
123+
run: dotnet fallout Test Pack PackageGuard
124124
env:
125125
# We check out the tag above, so HEAD is detached and matches none of
126126
# version.json's publicReleaseRefSpec entries (they're all branch refs,
127127
# and nbgv never matches that spec against refs/tags/*). Without this,
128128
# NB.GV treats the build as non-public and stamps a git-height suffix
129129
# onto every package — v10.4.0-rc.3 shipped as 10.4.0-rc.3.geabd043cc2.
130130
PublicRelease: true
131+
# PackageGuard's own target (build/Build.PackageGuard.cs) is gated on either being on
132+
# a long-lived branch or running in this workflow (GitHubActions.Workflow ==
133+
# "publish-packages-release") — the tag checkout above leaves HEAD detached, so the
134+
# branch check alone can't pass here; validate-ref already proved the tag is
135+
# reachable from a production branch. GITHUB_TOKEN lets it authenticate to GitHub
136+
# (license lookups) instead of hitting anonymous rate limits.
137+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
131138
- name: 'Upload: package artifacts'
132139
uses: actions/upload-artifact@v7
133140
with:
134141
name: packages
135142
path: output/packages/*.nupkg
136143
retention-days: 7
137144
if-no-files-found: error
145+
- name: 'Upload: PackageGuard SBOM + risk report'
146+
uses: actions/upload-artifact@v7
147+
with:
148+
name: packageguard
149+
path: output/packageguard/*
150+
retention-days: 7
151+
if-no-files-found: error
138152

139153
# Tier 1 — production / nuget.org. **OPT-IN ONLY.** Tag pushes do NOT trigger
140154
# this job; you must invoke workflow_dispatch with publish-to-nugetorg=true.
@@ -241,9 +255,11 @@ jobs:
241255
--skip-duplicate
242256
done
243257
244-
# Bundled artifact distribution. Attaches all nupkgs to the GitHub Release
245-
# for the tag. Idempotent: if the release already exists (workflow_dispatch
246-
# retry case), uploads or replaces missing assets via --clobber.
258+
# Bundled artifact distribution. Attaches all nupkgs, plus the PackageGuard SBOM and HTML
259+
# risk report, to the GitHub Release for the tag. Idempotent: if the release already exists
260+
# (workflow_dispatch retry case), uploads or replaces missing assets via --clobber. The .sarif
261+
# itself isn't attached here — it's reported to GitHub's code-scanning feature instead
262+
# (security-scan.yml); the HTML is the human-readable counterpart of the same report.
247263
publish-github-releases:
248264
name: publish → GitHub Releases
249265
runs-on: ubuntu-latest
@@ -262,6 +278,11 @@ jobs:
262278
with:
263279
name: packages
264280
path: output/packages
281+
- name: 'Download: PackageGuard SBOM + risk report'
282+
uses: actions/download-artifact@v8
283+
with:
284+
name: packageguard
285+
path: output/packageguard
265286
- name: 'Create or update GitHub Release with package artifacts'
266287
env:
267288
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -273,14 +294,21 @@ jobs:
273294
TAG="${GITHUB_REF_NAME}"
274295
fi
275296
echo "Tag: $TAG"
297+
assets=(output/packages/*.nupkg)
298+
if [ -f output/packageguard/sbom.json ]; then
299+
assets+=("output/packageguard/sbom.json")
300+
fi
301+
if [ -f output/packageguard/risk-report.html ]; then
302+
assets+=("output/packageguard/risk-report.html")
303+
fi
276304
if gh release view "$TAG" > /dev/null 2>&1; then
277305
echo "Release $TAG already exists — uploading assets with --clobber."
278-
gh release upload "$TAG" output/packages/*.nupkg --clobber
306+
gh release upload "$TAG" "${assets[@]}" --clobber
279307
else
280308
echo "Creating new release $TAG."
281309
gh release create "$TAG" \
282310
--title "$TAG" \
283311
--target "$(git rev-parse HEAD)" \
284312
--generate-notes \
285-
output/packages/*.nupkg
313+
"${assets[@]}"
286314
fi
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# ------------------------------------------------------------------------------
2+
# <auto-generated>
3+
#
4+
# This code was generated.
5+
#
6+
# - To turn off auto-generation set:
7+
#
8+
# [GitHubActions (AutoGenerate = false)]
9+
#
10+
# - To trigger manual generation invoke:
11+
#
12+
# fallout --generate-configuration GitHubActions_security-scan --host GitHubActions
13+
#
14+
# </auto-generated>
15+
# ------------------------------------------------------------------------------
16+
17+
name: security-scan
18+
19+
on:
20+
push:
21+
branches:
22+
- develop
23+
- main
24+
- 'release/*'
25+
- 'support/*'
26+
paths-ignore:
27+
- 'docs/**'
28+
- '.assets/**'
29+
- '**/*.md'
30+
31+
permissions:
32+
security-events: write
33+
contents: read
34+
35+
concurrency:
36+
group: ${{ github.workflow }}-${{ github.ref }}
37+
cancel-in-progress: true
38+
39+
jobs:
40+
ubuntu-latest:
41+
name: ubuntu-latest
42+
runs-on: ubuntu-latest
43+
steps:
44+
- uses: actions/checkout@v7
45+
with:
46+
fetch-depth: 0
47+
- name: 'Cache: .fallout/temp, ~/.nuget/packages'
48+
uses: actions/cache@v6
49+
with:
50+
path: |
51+
.fallout/temp
52+
~/.nuget/packages
53+
key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
54+
- name: 'Setup: .NET SDK'
55+
uses: actions/setup-dotnet@v6
56+
with:
57+
global-json-file: global.json
58+
- name: 'Restore: dotnet tools'
59+
run: dotnet tool restore
60+
- name: 'Run: PackageGuard'
61+
run: dotnet fallout PackageGuard
62+
env:
63+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
64+
- name: 'Upload risk-report SARIF to GitHub code scanning'
65+
uses: github/codeql-action/upload-sarif@v3
66+
with:
67+
sarif_file: output/packageguard/risk-report.sarif

.packageguard/config.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"Settings": {
3+
"Allow": {
4+
"Licenses": [
5+
"MIT",
6+
"Apache-2.0",
7+
"BSD-2-Clause",
8+
"BSD-3-Clause",
9+
"ISC",
10+
"0BSD",
11+
"MS-PL"
12+
],
13+
"Packages": [
14+
"FluentAssertions"
15+
]
16+
}
17+
}
18+
}

build/Build.CI.GitHubActions.cs

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
using System.Collections.Generic;
12
using Fallout.Common.CI.GitHubActions;
3+
using Fallout.Common.CI.GitHubActions.Configuration;
24
using Fallout.Components;
35

4-
// Two generated build workflows. Both run Test+Pack; both are GENERATED from the
5-
// attributes below — edit here and regenerate (`./build.sh`), never hand-edit the
6-
// `.yml`.
6+
// Three generated workflows. build.yml and build-cross-platform.yml both run Test+Pack; all
7+
// three are GENERATED from the attributes below — edit here and regenerate (`./build.sh`),
8+
// never hand-edit the `.yml`.
79
//
810
// build.yml — the Linux PR gate, and the ONLY required status
911
// check (job `ubuntu-latest`; branch protection keys on
@@ -15,7 +17,12 @@
1517
// merge SHA, keeping HEAD attached so
1618
// GitHubTasksTest.GitHubRepositoryFromLocalDirectoryTest
1719
// (which reads .git/HEAD via GitRepository.FromLocalDirectory)
18-
// resolves a non-null branch.
20+
// resolves a non-null branch. Also runs PackageGuard — its
21+
// policy-violation check gates every PR, though the target skips
22+
// SBOM/risk-report generation here (build/Build.PackageGuard.cs,
23+
// IsOnLongLivedBranch — this checkout is never on one of the four).
24+
// EnableGitHubToken avoids anonymous GitHub API rate-limiting on
25+
// PackageGuard's license lookups, now that it runs on every PR.
1926
//
2027
// build-cross-platform.yml — macOS + Windows in ONE workflow (one job per image).
2128
// Cross-platform full Test+Pack is gated to RELEASE
@@ -44,7 +51,8 @@
4451
// long-lived and protected; all require the ubuntu-latest check.
4552
OnPullRequestBranches = new[] { DevelopBranch, MainBranch, ReleaseBranchPattern, SupportBranchPattern },
4653
OnPullRequestExcludePaths = new[] { "docs/**", ".assets/**", "**/*.md" },
47-
InvokedTargets = new[] { nameof(VerifyGeneratedTools), nameof(ITest.Test), nameof(IPack.Pack) },
54+
InvokedTargets = new[] { nameof(VerifyGeneratedTools), nameof(ITest.Test), nameof(IPack.Pack), nameof(PackageGuard) },
55+
EnableGitHubToken = true,
4856
PublishArtifacts = false)]
4957
[GitHubActions(
5058
"build-cross-platform",
@@ -61,7 +69,34 @@
6169
OnPullRequestExcludePaths = new[] { "docs/**", ".assets/**", "**/*.md" },
6270
InvokedTargets = new[] { nameof(ITest.Test), nameof(IPack.Pack) },
6371
PublishArtifacts = false)]
64-
partial class Build
72+
// security-scan.yml — continuous SBOM + risk-report generation
73+
// (build/Build.PackageGuard.cs). PackageGuard's policy-violation
74+
// check already runs on every PR via build.yml above; this workflow
75+
// is for the SBOM/SARIF/HTML side, which build.yml's target
76+
// deliberately skips (its checkout is never one of the four
77+
// long-lived branches, so IsOnLongLivedBranch is false there).
78+
// Push-only, and only to develop/main/release/*/support/* — a push
79+
// is when there's actually a new commit on one of those branches to
80+
// report on. EnableGitHubToken feeds GITHUB_TOKEN to PackageGuard
81+
// (avoids GitHub API rate-limiting on license lookups) and to the
82+
// upload-sarif step's security-events:write use.
83+
[GitHubActions(
84+
"security-scan",
85+
GitHubActionsImage.UbuntuLatest,
86+
FetchDepth = 0,
87+
ConcurrencyGroup = "${{ github.workflow }}-${{ github.ref }}",
88+
ConcurrencyCancelInProgress = true,
89+
OnPushBranches = new[] { DevelopBranch, MainBranch, ReleaseBranchPattern, SupportBranchPattern },
90+
OnPushExcludePaths = new[] { "docs/**", ".assets/**", "**/*.md" },
91+
InvokedTargets = new[] { nameof(PackageGuard) },
92+
EnableGitHubToken = true,
93+
// Specifying any `permissions:` block switches the job from GitHub's default read-all to
94+
// explicit-only — contents:read has to be listed too, or upload-sarif (and checkout) lose
95+
// it. See GitHub's own upload-sarif docs for this exact pairing.
96+
ReadPermissions = new[] { GitHubActionsPermissions.Contents },
97+
WritePermissions = new[] { GitHubActionsPermissions.SecurityEvents },
98+
PublishArtifacts = false)]
99+
partial class Build : IConfigureGitHubActions
65100
{
66101
// The release workflow is intentionally hand-written at
67102
// .github/workflows/publish-packages-release.yml — that lets us name the GitHub
@@ -71,4 +106,24 @@ partial class Build
71106
// workflow's `name:` — it gates ICreateGitHubRelease.CreateGitHubRelease
72107
// (Build.cs) to the release workflow only.
73108
const string ReleaseWorkflow = "publish-packages-release";
109+
110+
// Injects the SARIF upload after security-scan's "dotnet fallout PackageGuard" run step —
111+
// GitHubActionsStepPosition.PostRun is exactly "after the run block, before the built-in
112+
// artifact upload". Scoped to this one generated job by WorkflowName; other jobs get no
113+
// insertions.
114+
void IConfigureGitHubActions.ConfigureSteps(GitHubActionsStepPipeline pipeline)
115+
{
116+
if (pipeline.WorkflowName == "security-scan")
117+
{
118+
pipeline.Insert(GitHubActionsStepPosition.PostRun, new GitHubActionsCustomStep
119+
{
120+
Name = "Upload risk-report SARIF to GitHub code scanning",
121+
Uses = "github/codeql-action/upload-sarif@v3",
122+
With = new Dictionary<string, string>
123+
{
124+
["sarif_file"] = "output/packageguard/risk-report.sarif",
125+
},
126+
});
127+
}
128+
}
74129
}

build/Build.PackageGuard.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using Fallout.Common;
2+
using Fallout.Common.Git;
3+
using Fallout.Common.IO;
4+
using Fallout.Common.Tooling;
5+
using Fallout.Common.Tools.PackageGuard;
6+
using Fallout.Common.Utilities;
7+
using Fallout.Components;
8+
9+
partial class Build
10+
{
11+
// PackageGuard's own CLI only exposes an env-var override for the risk-report path
12+
// (see PackageGuard.json's help text on ReportRisk) — there's no `--report-risk <path>`
13+
// argument in our wrapper, matching the repo-wide convention that bool CLI flags stay
14+
// presence-only. Setting this process env var instead pins the SARIF/HTML pair to a
15+
// deterministic path we can reference from CI (upload-sarif, the release asset step).
16+
const string PackageGuardReportRiskPathOverrideEnvironmentVariable = "PACKAGEGUARD_REPORT_RISK_PATH_OVERRIDE";
17+
18+
AbsolutePath PackageGuardDirectory => OutputDirectory / "packageguard";
19+
AbsolutePath PackageGuardSbomFile => PackageGuardDirectory / "sbom.json";
20+
AbsolutePath PackageGuardSarifFile => PackageGuardDirectory / "risk-report.sarif";
21+
22+
// The SBOM and risk report (HTML + SARIF) are only worth generating where they're actually
23+
// consumed: security-scan.yml uploads the SARIF on a push to one of these four branches, and
24+
// the release workflow attaches the SBOM/HTML to the GitHub Release. Neither happens on a PR
25+
// (build.yml checks out the contributor's own branch via github.head_ref, never one of
26+
// these), or on a tag-triggered release checkout (detached HEAD — hence the
27+
// GitHubActions.Workflow fallback, since that workflow's own validate-ref job already
28+
// proved the tag is reachable from a production branch).
29+
bool IsOnLongLivedBranch =>
30+
GitRepository.IsOnMainBranch() ||
31+
GitRepository.IsOnDevelopBranch() ||
32+
GitRepository.IsOnReleaseBranch() ||
33+
GitRepository.IsOnSupportBranch() ||
34+
GitHubActions?.Workflow == ReleaseWorkflow;
35+
36+
// Runs unconditionally — this is the PR gate's policy-violation check (build.yml), so it has
37+
// to run on every branch, including a contributor's feature branch. Only the SBOM/risk-report
38+
// generation is restricted to the four long-lived branches, via IsOnLongLivedBranch below.
39+
Target PackageGuard => _ => _
40+
.DependsOn<IRestore>()
41+
.Produces(PackageGuardSbomFile)
42+
.Produces(PackageGuardSarifFile)
43+
.Produces(PackageGuardDirectory / "*.html")
44+
.Executes(() =>
45+
{
46+
var generateReports = IsOnLongLivedBranch;
47+
48+
if (generateReports)
49+
PackageGuardDirectory.CreateOrCleanDirectory();
50+
51+
PackageGuardTasks.PackageGuard(_ => _
52+
.SetProjectPath(Solution.Path)
53+
.SetGitHubApiKey(From<ICreateGitHubRelease>().GitHubToken)
54+
.When(generateReports, _ => _
55+
.EnableReportRisk()
56+
.SetSbom(SbomFormat.cyclonedx)
57+
.SetSbomOutput(PackageGuardSbomFile)
58+
.SetProcessEnvironmentVariable(PackageGuardReportRiskPathOverrideEnvironmentVariable, PackageGuardSarifFile)));
59+
});
60+
}

build/_build.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
Build.cs as the canonical version source. Replaces GitVersion.Tool (#81). Direct
5959
dependency because _build.csproj turns off central package management. -->
6060
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115" PrivateAssets="All" />
61+
<PackageDownload Include="PackageGuard" Version="[2.4.0]" />
6162
<PackageDownload Include="ReportGenerator" Version="[5.2.0]" />
6263
<!-- Used by Fallout.Tooling.Tests' ToolTasksToolPathTest as a sample package
6364
for tool-path resolution smoke tests. Not used for running our own tests. -->

0 commit comments

Comments
 (0)