diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 729bf0b5..ba76cf69 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -61,6 +61,12 @@ jobs:
- name: Restore dependencies
run: dotnet restore FolderDiffIL4DotNet.sln
+ - name: Test NuGet audit gate
+ run: python3 -m unittest discover -s scripts/tests -p 'test_*.py'
+
+ - name: Audit NuGet dependencies
+ run: python3 scripts/nuget_audit_gate.py --solution FolderDiffIL4DotNet.sln
+
- name: Build
run: dotnet build FolderDiffIL4DotNet.sln --configuration Release --no-restore
diff --git a/.gitignore b/.gitignore
index 85fb7ac0..928242f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,5 +32,9 @@ api/toc.yml
# Node.js (Jest JS tests)
node_modules/
+# Python
+__pycache__/
+*.py[cod]
+
# Claude Code runtime state
.claude/scheduled_tasks.lock
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d4a9b669..1895d712 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
#### Added
+- **Transitive NuGet vulnerability CI gate** — The main CI workflow now audits every solution project for direct and transitive NuGet advisories, publishes the complete result in the log and GitHub job summary, and fails closed on High/Critical findings or invalid audit output. `FsCheck.Xunit` was upgraded from 2.16.6 to 3.3.3, removing the vulnerable `System.Net.Http` and `System.Security.Cryptography.X509Certificates` test dependency chain; the property tests were migrated to the FsCheck 3 fluent API. Local reproduction and the temporary-exception policy are documented in the testing guide. Affected: `.github/workflows/dotnet.yml`, `.gitignore`, `scripts/nuget_audit_gate.py`, `scripts/tests/test_nuget_audit_gate.py`, `FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj`, `FolderDiffIL4DotNet.Tests/PropertyBased/PropertyBasedTests.cs`, `FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs`, `doc/TESTING_GUIDE.md`.
+
- **First-class npm/Jest CI gate** — The main CI workflow now pins Node.js through `.node-version`, restores the exact `package-lock.json` graph with cached `npm ci`, runs the complete HTML-report Jest suite, and rejects High/Critical npm advisories. The lockfile refresh remediates the five findings reported when this work began. A newly published `brace-expansion` advisory (`GHSA-mh99-v99m-4gvg`) is isolated to development-only Jest tooling and has a documented exception that expires on 2026-08-31; every other High/Critical finding still fails the gate. Dependabot now opens weekly npm update pull requests that run the same checks. Affected: `.node-version`, `.github/workflows/dotnet.yml`, `.github/dependabot.yml`, `package.json`, `package-lock.json`, `npm-audit-exceptions.json`, `scripts/npm-audit-gate.js`, `doc/TESTING_GUIDE.md`. Tests: `npm_audit_gate.test.js`, `CiAutomationConfigurationTests`.
- **Opt-in `--fail-on-diff` CI gating** — Normal completed comparisons continue to return `0` by default. With `--fail-on-diff`, nildiff now returns dedicated exit code `5` when the final reportable Added/Removed/Modified sets are non-empty. The decision is made only after every enabled report, audit log, and post-process action completes, and ignored extensions or other suppressed/filtered differences do not trigger the gate. Affected: `Runner/CliParser.cs`, `Runner/CliOptions.cs`, `ProgramRunner.cs`, `Runner/ProgramRunner.Types.cs`, `Runner/ProgramRunner.Wizard.cs`, `Runner/ProgramRunner.HelpText.cs`, `README.md`, `USER_GUIDE.md`. Tests: `CliOptionsTests`, `ProgramRunnerTests`.
@@ -1678,6 +1680,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
#### 追加
+- **推移的 NuGet 脆弱性 CI ゲート** — メイン CI ワークフローはソリューション内の全 project を対象に NuGet の直接・推移的 advisory を監査し、完全な結果をログと GitHub job summary に公開して、High/Critical の検出または不正な監査出力を fail-closed で失敗させるようになりました。`FsCheck.Xunit` を 2.16.6 から 3.3.3 へ更新し、脆弱な `System.Net.Http` と `System.Security.Cryptography.X509Certificates` のテスト依存チェーンを解消しました。プロパティテストは FsCheck 3 fluent API へ移行しています。ローカル再現手順と一時例外ポリシーはテストガイドに記載しました。対象: `.github/workflows/dotnet.yml`, `.gitignore`, `scripts/nuget_audit_gate.py`, `scripts/tests/test_nuget_audit_gate.py`, `FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj`, `FolderDiffIL4DotNet.Tests/PropertyBased/PropertyBasedTests.cs`, `FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs`, `doc/TESTING_GUIDE.md`。
+
- **npm/Jest を第一級 CI ゲート化** — メイン CI ワークフローは `.node-version` で Node.js を固定し、`package-lock.json` の依存グラフをキャッシュ付き `npm ci` で厳密に復元して、HTML レポートの Jest 全テストと High/Critical npm advisory の拒否を実行するようになりました。ロックファイル更新により着手時点で報告されていた High 5 件を解消しました。作業中に新規公開された `brace-expansion` advisory(`GHSA-mh99-v99m-4gvg`)は開発専用の Jest ツールに限定されるため、2026-08-31 に失効する例外として明記し、それ以外の High/Critical はすべて引き続きゲートを失敗させます。Dependabot は同じチェックを通る npm 更新 PR を週次で作成します。対象: `.node-version`, `.github/workflows/dotnet.yml`, `.github/dependabot.yml`, `package.json`, `package-lock.json`, `npm-audit-exceptions.json`, `scripts/npm-audit-gate.js`, `doc/TESTING_GUIDE.md`。テスト: `npm_audit_gate.test.js`, `CiAutomationConfigurationTests`。
- **opt-in の `--fail-on-diff` CI ゲート** — 正常に完了した比較は既定で従来どおり `0` を返します。`--fail-on-diff` を指定した場合は、最終的なレポート対象の Added/Removed/Modified が空でなければ専用終了コード `5` を返します。判定は有効なレポート、監査ログ、ポストプロセス処理をすべて完了した後にだけ行い、無視拡張子やその他の抑制・フィルタ済み差分はゲートを発火させません。対象: `Runner/CliParser.cs`, `Runner/CliOptions.cs`, `ProgramRunner.cs`, `Runner/ProgramRunner.Types.cs`, `Runner/ProgramRunner.Wizard.cs`, `Runner/ProgramRunner.HelpText.cs`, `README.md`, `USER_GUIDE.md`。テスト: `CliOptionsTests`, `ProgramRunnerTests`。
diff --git a/FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs b/FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs
index b7a45f08..0823ab42 100644
--- a/FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs
+++ b/FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs
@@ -103,6 +103,36 @@ public void DotNetWorkflow_RunsPinnedJavaScriptTestsAndAuditGate()
Assert.False(string.IsNullOrWhiteSpace(auditException.GetProperty("scope").GetString()));
}
+ ///
+ /// Verifies that CI audits direct and transitive NuGet packages and fails on High/Critical findings.
+ /// CI が NuGet の直接・推移的 package を監査し、High/Critical の検出を失敗させることを検証します。
+ ///
+ [Fact]
+ public void DotNetWorkflow_AuditsDirectAndTransitiveNuGetPackages()
+ {
+ var workflow = File.ReadAllText(GetRepositoryFilePath(".github", "workflows", "dotnet.yml"));
+ var auditGate = File.ReadAllText(GetRepositoryFilePath("scripts", "nuget_audit_gate.py"));
+ var testProject = File.ReadAllText(
+ GetRepositoryFilePath("FolderDiffIL4DotNet.Tests", "FolderDiffIL4DotNet.Tests.csproj"));
+
+ Assert.Contains("Test NuGet audit gate", workflow, StringComparison.Ordinal);
+ Assert.Contains(
+ "python3 -m unittest discover -s scripts/tests -p 'test_*.py'",
+ workflow,
+ StringComparison.Ordinal);
+ Assert.Contains("Audit NuGet dependencies", workflow, StringComparison.Ordinal);
+ Assert.Contains(
+ "python3 scripts/nuget_audit_gate.py --solution FolderDiffIL4DotNet.sln",
+ workflow,
+ StringComparison.Ordinal);
+ Assert.Contains("\"--vulnerable\"", auditGate, StringComparison.Ordinal);
+ Assert.Contains("\"--include-transitive\"", auditGate, StringComparison.Ordinal);
+ Assert.Contains("BLOCKING_SEVERITIES = {\"high\", \"critical\"}", auditGate, StringComparison.Ordinal);
+ Assert.Contains("NUGET_AUDIT_SOURCE = \"https://api.nuget.org/v3/index.json\"", auditGate, StringComparison.Ordinal);
+ Assert.Contains("GITHUB_STEP_SUMMARY", auditGate, StringComparison.Ordinal);
+ Assert.Contains("FsCheck.Xunit\" Version=\"3.3.3\"", testProject, StringComparison.Ordinal);
+ }
+
///
/// Verifies that tagged builds create a GitHub release with attached publish and documentation artifacts.
/// タグ付きビルドが公開・ドキュメント成果物を添付した GitHub リリースを作成することを検証します。
diff --git a/FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj b/FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj
index 65cd1550..07282e38 100644
--- a/FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj
+++ b/FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj
@@ -21,7 +21,7 @@
all
-
+
diff --git a/FolderDiffIL4DotNet.Tests/PropertyBased/PropertyBasedTests.cs b/FolderDiffIL4DotNet.Tests/PropertyBased/PropertyBasedTests.cs
index 4a5b910e..bd703306 100644
--- a/FolderDiffIL4DotNet.Tests/PropertyBased/PropertyBasedTests.cs
+++ b/FolderDiffIL4DotNet.Tests/PropertyBased/PropertyBasedTests.cs
@@ -4,8 +4,11 @@
using FolderDiffIL4DotNet.Core.Text;
using FolderDiffIL4DotNet.Models;
using FsCheck;
+using FsCheck.Fluent;
using FsCheck.Xunit;
using Xunit;
+using FluentArbMap = FsCheck.Fluent.ArbMap;
+using FluentGen = FsCheck.Fluent.Gen;
namespace FolderDiffIL4DotNet.Tests.PropertyBased
{
@@ -164,9 +167,11 @@ public static class NonNullStringArrayArbitrary
{
public static Arbitrary StringArray()
{
- return Gen.Choose(0, 30)
+ return FluentGen.Choose(0, 30)
.SelectMany(len =>
- Gen.ArrayOf(len, Arb.Generate>().Select(s => s.Get)))
+ FluentGen.ArrayOf(
+ FluentArbMap.Default.GeneratorFor>().Select(s => s.Get),
+ len))
.ToArbitrary();
}
}
diff --git a/doc/TESTING_GUIDE.md b/doc/TESTING_GUIDE.md
index ba408106..f1e7e2c8 100644
--- a/doc/TESTING_GUIDE.md
+++ b/doc/TESTING_GUIDE.md
@@ -138,6 +138,15 @@ npm run audit:high
The audit gate fails on every High/Critical advisory except the exact, time-bounded entries in [`npm-audit-exceptions.json`](../npm-audit-exceptions.json). The current `GHSA-mh99-v99m-4gvg` exception is limited to the development-only Jest dependency chain because the latest Jest 30.4.x graph cannot yet select the patched `brace-expansion` major safely; a separate `--omit=dev` audit enforces that scope, and the exception expires on 2026-08-31. [`scripts/npm-audit-gate.js`](../scripts/npm-audit-gate.js) also fails on an expired exception or if npm audit cannot return a valid report.
+Run the NuGet audit-gate tests and the same full-solution direct/transitive dependency audit used by CI:
+
+```bash
+python3 -m unittest discover -s scripts/tests -p 'test_*.py'
+python3 scripts/nuget_audit_gate.py --solution FolderDiffIL4DotNet.sln
+```
+
+[`scripts/nuget_audit_gate.py`](../scripts/nuget_audit_gate.py) runs `dotnet list FolderDiffIL4DotNet.sln package --vulnerable --include-transitive --format json --output-version 1 --source https://api.nuget.org/v3/index.json`, prints every reported advisory, writes the result to the GitHub job summary when available, and fails on any direct or transitive High/Critical finding. It explicitly requires the advisory-capable nuget.org source and fails closed if the command or JSON report is invalid or incomplete. There are no active NuGet audit exceptions; any proposed temporary exception must document its advisory, rationale, exact dependency/project scope, and expiry before the CI policy is changed.
+
Run performance benchmarks (BenchmarkDotNet):
```bash
@@ -375,6 +384,15 @@ npm run audit:high
監査ゲートは、[`npm-audit-exceptions.json`](../npm-audit-exceptions.json) に完全一致かつ期限付きで記録した項目を除き、High/Critical advisory をすべて失敗させます。現在の `GHSA-mh99-v99m-4gvg` 例外は、最新 Jest 30.4.x の依存グラフが修正版 `brace-expansion` のメジャーバージョンをまだ安全に選択できないため、開発専用 Jest 依存チェーンだけに限定します。この範囲は別の `--omit=dev` 監査で強制し、例外は 2026-08-31 に失効します。[`scripts/npm-audit-gate.js`](../scripts/npm-audit-gate.js) は、例外が期限切れの場合や npm audit から有効なレポートを取得できない場合も失敗します。
+NuGet 監査ゲートのテストと、CI と同じソリューション全体の直接・推移的依存関係監査を実行する場合:
+
+```bash
+python3 -m unittest discover -s scripts/tests -p 'test_*.py'
+python3 scripts/nuget_audit_gate.py --solution FolderDiffIL4DotNet.sln
+```
+
+[`scripts/nuget_audit_gate.py`](../scripts/nuget_audit_gate.py) は `dotnet list FolderDiffIL4DotNet.sln package --vulnerable --include-transitive --format json --output-version 1 --source https://api.nuget.org/v3/index.json` を実行し、報告された advisory をすべて表示します。利用可能な場合は GitHub job summary にも結果を書き込み、直接・推移的依存関係の High/Critical 検出を失敗させます。advisory 対応の nuget.org source を明示的に必須とし、コマンドや JSON レポートが不正・不完全な場合も fail-closed で失敗します。現在有効な NuGet 監査例外はありません。将来一時例外を提案する場合は、CI ポリシーを変更する前に advisory、理由、正確な依存関係/project の範囲、失効日を記録する必要があります。
+
パフォーマンスベンチマーク(BenchmarkDotNet)を実行する場合:
```bash
diff --git a/scripts/nuget_audit_gate.py b/scripts/nuget_audit_gate.py
new file mode 100644
index 00000000..00c37b49
--- /dev/null
+++ b/scripts/nuget_audit_gate.py
@@ -0,0 +1,370 @@
+#!/usr/bin/env python3
+"""
+Audits direct and transitive NuGet dependencies and blocks High/Critical findings.
+NuGet の直接・推移的依存関係を監査し、High/Critical の検出をブロックします。
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+
+BLOCKING_SEVERITIES = {"high", "critical"}
+KNOWN_SEVERITIES = {"low", "moderate", "high", "critical"}
+NUGET_AUDIT_SOURCE = "https://api.nuget.org/v3/index.json"
+
+
+class AuditError(RuntimeError):
+ """
+ Indicates that the audit could not produce a trustworthy result.
+ 信頼できる監査結果を生成できなかったことを示します。
+ """
+
+
+@dataclass(frozen=True)
+class Finding:
+ project: str
+ framework: str
+ dependency: str
+ package: str
+ version: str
+ severity: str
+ advisory_url: str
+
+ @property
+ def is_blocking(self) -> bool:
+ """
+ Returns whether this finding blocks CI.
+ この検出が CI をブロックするかを返します。
+ """
+ return self.severity.lower() in BLOCKING_SEVERITIES
+
+
+def parse_args() -> argparse.Namespace:
+ """
+ Parses CLI arguments.
+ CLI 引数を解析します。
+ """
+ parser = argparse.ArgumentParser(
+ description=(
+ "Fail on High/Critical direct or transitive NuGet vulnerabilities.\n"
+ "NuGet の直接・推移的な High/Critical 脆弱性を失敗させます。"
+ )
+ )
+ parser.add_argument("--solution", default="FolderDiffIL4DotNet.sln")
+ parser.add_argument(
+ "--input",
+ type=Path,
+ help=(
+ "Read an existing dotnet list JSON report instead of running dotnet.\n"
+ "dotnet を実行せず既存 JSON レポートを読み込みます。"
+ ),
+ )
+ return parser.parse_args()
+
+
+def run_dotnet_audit(solution: str) -> dict[str, Any]:
+ """
+ Runs the canonical NuGet audit command.
+ 正規の NuGet 監査コマンドを実行します。
+ """
+ command = [
+ "dotnet",
+ "list",
+ solution,
+ "package",
+ "--vulnerable",
+ "--include-transitive",
+ "--format",
+ "json",
+ "--output-version",
+ "1",
+ "--source",
+ NUGET_AUDIT_SOURCE,
+ ]
+ completed = subprocess.run(command, capture_output=True, text=True, check=False)
+ if completed.stderr:
+ print(completed.stderr, file=sys.stderr, end="")
+ if completed.returncode != 0:
+ raise AuditError(
+ f"dotnet list package failed with exit code {completed.returncode}."
+ )
+
+ return parse_report(completed.stdout, "dotnet list package output")
+
+
+def load_report(path: Path) -> dict[str, Any]:
+ """
+ Loads an audit report fixture.
+ 監査レポートの fixture を読み込みます。
+ """
+ try:
+ return parse_report(path.read_text(encoding="utf-8-sig"), str(path))
+ except OSError as error:
+ raise AuditError(f"Could not read audit report '{path}': {error}") from error
+
+
+def parse_report(contents: str, source: str) -> dict[str, Any]:
+ """
+ Parses and validates the report's JSON root.
+ レポートの JSON ルートを解析・検証します。
+ """
+ try:
+ report = json.loads(contents.lstrip("\ufeff"))
+ except json.JSONDecodeError as error:
+ raise AuditError(f"{source} is not valid JSON: {error}") from error
+
+ if not isinstance(report, dict):
+ raise AuditError(f"{source} must contain a JSON object.")
+ return report
+
+
+def inspect_report(report: dict[str, Any]) -> tuple[int, list[Finding]]:
+ """
+ Validates the NuGet schema and extracts every finding.
+ NuGet スキーマを検証し、全検出を抽出します。
+ """
+ if report.get("version") != 1:
+ raise AuditError("NuGet audit report must use output version 1.")
+
+ parameters = report.get("parameters")
+ if not isinstance(parameters, str):
+ raise AuditError("NuGet audit report is missing its parameters.")
+ for required_parameter in ("--vulnerable", "--include-transitive"):
+ if required_parameter not in parameters.split():
+ raise AuditError(
+ f"NuGet audit report was not generated with {required_parameter}."
+ )
+
+ sources = report.get("sources")
+ if not isinstance(sources, list) or NUGET_AUDIT_SOURCE not in sources:
+ raise AuditError(
+ f"NuGet audit report did not query the required advisory source {NUGET_AUDIT_SOURCE}."
+ )
+
+ projects = report.get("projects")
+ if not isinstance(projects, list) or not projects:
+ raise AuditError("NuGet audit report did not contain any projects.")
+
+ findings: list[Finding] = []
+ for project in projects:
+ if not isinstance(project, dict):
+ raise AuditError("NuGet audit report contains an invalid project entry.")
+ project_path = project.get("path")
+ if not isinstance(project_path, str) or not project_path:
+ raise AuditError("NuGet audit report contains a project without a path.")
+
+ frameworks = project.get("frameworks", [])
+ if not isinstance(frameworks, list):
+ raise AuditError(f"Project '{project_path}' has an invalid frameworks list.")
+
+ for framework in frameworks:
+ if not isinstance(framework, dict):
+ raise AuditError(f"Project '{project_path}' has an invalid framework entry.")
+ framework_name = framework.get("framework")
+ if not isinstance(framework_name, str) or not framework_name:
+ raise AuditError(f"Project '{project_path}' has a framework without a name.")
+
+ for package_key, dependency in (
+ ("topLevelPackages", "Direct"),
+ ("transitivePackages", "Transitive"),
+ ):
+ packages = framework.get(package_key, [])
+ if not isinstance(packages, list):
+ raise AuditError(
+ f"Project '{project_path}' has an invalid {package_key} list."
+ )
+ findings.extend(
+ inspect_packages(
+ packages,
+ project_path,
+ framework_name,
+ dependency,
+ )
+ )
+
+ return len(projects), findings
+
+
+def inspect_packages(
+ packages: list[Any],
+ project: str,
+ framework: str,
+ dependency: str,
+) -> list[Finding]:
+ """
+ Extracts findings from one package section.
+ 1つの package セクションから検出を抽出します。
+ """
+ findings: list[Finding] = []
+ for package in packages:
+ if not isinstance(package, dict):
+ raise AuditError(f"Project '{project}' has an invalid package entry.")
+
+ package_id = package.get("id")
+ version = package.get("resolvedVersion")
+ vulnerabilities = package.get("vulnerabilities")
+ if not isinstance(package_id, str) or not package_id:
+ raise AuditError(f"Project '{project}' has a package without an id.")
+ if not isinstance(version, str) or not version:
+ raise AuditError(f"Package '{package_id}' does not have a resolved version.")
+ if not isinstance(vulnerabilities, list) or not vulnerabilities:
+ raise AuditError(
+ f"Package '{package_id}' does not contain any vulnerability records."
+ )
+
+ for vulnerability in vulnerabilities:
+ if not isinstance(vulnerability, dict):
+ raise AuditError(f"Package '{package_id}' has an invalid vulnerability entry.")
+ severity = vulnerability.get("severity")
+ advisory_url = vulnerability.get("advisoryurl")
+ if not isinstance(severity, str) or severity.lower() not in KNOWN_SEVERITIES:
+ raise AuditError(
+ f"Package '{package_id}' has an unknown vulnerability severity '{severity}'."
+ )
+ if not isinstance(advisory_url, str) or not advisory_url:
+ raise AuditError(f"Package '{package_id}' has a vulnerability without an advisory URL.")
+
+ findings.append(
+ Finding(
+ project=display_project_path(project),
+ framework=framework,
+ dependency=dependency,
+ package=package_id,
+ version=version,
+ severity=severity,
+ advisory_url=advisory_url,
+ )
+ )
+
+ return findings
+
+
+def display_project_path(project: str) -> str:
+ """
+ Makes absolute project paths concise in local and CI output.
+ ローカル・CI 出力の絶対 project path を簡潔にします。
+ """
+ path = Path(project)
+ if not path.is_absolute():
+ return path.as_posix()
+ try:
+ return Path(os.path.relpath(path, Path.cwd())).as_posix()
+ except ValueError:
+ return path.as_posix()
+
+
+def escape_markdown_cell(value: str) -> str:
+ """
+ Escapes a markdown table cell.
+ markdown テーブルセルをエスケープします。
+ """
+ return value.replace("\\", "\\\\").replace("|", "\\|").replace("\r", " ").replace("\n", " ")
+
+
+def build_summary(project_count: int, findings: list[Finding]) -> str:
+ """
+ Builds the log and GitHub job summary.
+ ログと GitHub job summary を構築します。
+ """
+ blocking_count = sum(finding.is_blocking for finding in findings)
+ lines = [
+ "## NuGet vulnerability audit",
+ "",
+ (
+ f"Audited {project_count} solution projects for direct and transitive "
+ f"vulnerabilities; found {len(findings)} advisory entries."
+ ),
+ "",
+ ]
+
+ if findings:
+ lines.extend(
+ [
+ "| Project | Framework | Dependency | Package | Version | Severity | Advisory |",
+ "| --- | --- | --- | --- | --- | --- | --- |",
+ ]
+ )
+ for finding in findings:
+ values = (
+ finding.project,
+ finding.framework,
+ finding.dependency,
+ finding.package,
+ finding.version,
+ finding.severity,
+ finding.advisory_url,
+ )
+ lines.append("| " + " | ".join(escape_markdown_cell(value) for value in values) + " |")
+ lines.append("")
+ else:
+ lines.extend(["No vulnerable NuGet packages were reported.", ""])
+
+ if blocking_count:
+ lines.append(
+ f"**FAIL:** {blocking_count} High/Critical finding(s) block this build."
+ )
+ else:
+ lines.append("**PASS:** No High/Critical findings.")
+
+ return "\n".join(lines) + "\n"
+
+
+def build_error_summary(error: AuditError) -> str:
+ """
+ Builds a fail-closed error summary.
+ fail-closed のエラーサマリーを構築します。
+ """
+ return (
+ "## NuGet vulnerability audit\n\n"
+ f"**ERROR:** The audit could not produce a trustworthy result: {error}\n"
+ )
+
+
+def append_job_summary(summary: str) -> None:
+ """
+ Appends output to the GitHub job summary when available.
+ 利用可能な場合 GitHub job summary へ追記します。
+ """
+ summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
+ if not summary_path:
+ return
+ try:
+ with Path(summary_path).open("a", encoding="utf-8") as handle:
+ handle.write(summary)
+ except OSError as error:
+ raise AuditError(f"Could not write GITHUB_STEP_SUMMARY: {error}") from error
+
+
+def main() -> int:
+ """
+ Runs the audit gate.
+ 監査ゲートを実行します。
+ """
+ args = parse_args()
+ try:
+ report = load_report(args.input) if args.input else run_dotnet_audit(args.solution)
+ project_count, findings = inspect_report(report)
+ summary = build_summary(project_count, findings)
+ print(summary, end="")
+ append_job_summary(summary)
+ return 1 if any(finding.is_blocking for finding in findings) else 0
+ except AuditError as error:
+ summary = build_error_summary(error)
+ print(summary, file=sys.stderr, end="")
+ try:
+ append_job_summary(summary)
+ except AuditError as summary_error:
+ print(f"Additionally, {summary_error}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/tests/test_nuget_audit_gate.py b/scripts/tests/test_nuget_audit_gate.py
new file mode 100644
index 00000000..5416fe80
--- /dev/null
+++ b/scripts/tests/test_nuget_audit_gate.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+"""
+Tests the NuGet vulnerability audit gate.
+NuGet 脆弱性監査ゲートをテストします。
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+SCRIPT_PATH = Path(__file__).resolve().parent.parent / "nuget_audit_gate.py"
+NUGET_AUDIT_SOURCE = "https://api.nuget.org/v3/index.json"
+
+
+def package(package_id: str, severity: str, advisory: str) -> dict:
+ return {
+ "id": package_id,
+ "resolvedVersion": "1.2.3",
+ "vulnerabilities": [
+ {
+ "severity": severity,
+ "advisoryurl": advisory,
+ }
+ ],
+ }
+
+
+def report(*projects: dict) -> dict:
+ return {
+ "version": 1,
+ "parameters": "--vulnerable --include-transitive",
+ "sources": [NUGET_AUDIT_SOURCE],
+ "projects": list(projects),
+ }
+
+
+class NuGetAuditGateTests(unittest.TestCase):
+ def run_gate(self, payload: dict, summary_path: Path | None = None) -> subprocess.CompletedProcess[str]:
+ with tempfile.TemporaryDirectory() as temp_directory:
+ fixture_path = Path(temp_directory) / "audit.json"
+ fixture_path.write_text(json.dumps(payload), encoding="utf-8")
+ environment = os.environ.copy()
+ if summary_path is None:
+ environment.pop("GITHUB_STEP_SUMMARY", None)
+ else:
+ environment["GITHUB_STEP_SUMMARY"] = str(summary_path)
+ return subprocess.run(
+ [sys.executable, str(SCRIPT_PATH), "--input", str(fixture_path)],
+ capture_output=True,
+ text=True,
+ check=False,
+ env=environment,
+ )
+
+ def test_clean_report_passes_and_writes_job_summary(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_directory:
+ summary_path = Path(temp_directory) / "summary.md"
+ result = self.run_gate(
+ report(
+ {"path": "Product.csproj"},
+ {"path": "Product.Tests.csproj"},
+ ),
+ summary_path,
+ )
+
+ self.assertEqual(0, result.returncode, result.stderr)
+ self.assertIn("Audited 2 solution projects", result.stdout)
+ self.assertIn("No vulnerable NuGet packages", result.stdout)
+ self.assertIn("**PASS:**", summary_path.read_text(encoding="utf-8"))
+
+ def test_moderate_finding_is_visible_but_does_not_block(self) -> None:
+ result = self.run_gate(
+ report(
+ {
+ "path": "Product.csproj",
+ "frameworks": [
+ {
+ "framework": "net8.0",
+ "topLevelPackages": [
+ package(
+ "Example.Direct",
+ "Moderate",
+ "https://github.com/advisories/GHSA-moderate",
+ )
+ ],
+ }
+ ],
+ }
+ )
+ )
+
+ self.assertEqual(0, result.returncode, result.stderr)
+ self.assertIn("Direct", result.stdout)
+ self.assertIn("Moderate", result.stdout)
+ self.assertIn("**PASS:**", result.stdout)
+
+ def test_high_and_critical_findings_block_direct_and_transitive_packages(self) -> None:
+ result = self.run_gate(
+ report(
+ {
+ "path": "Product.Tests.csproj",
+ "frameworks": [
+ {
+ "framework": "net8.0",
+ "topLevelPackages": [
+ package(
+ "Example.Direct",
+ "Critical",
+ "https://github.com/advisories/GHSA-critical",
+ )
+ ],
+ "transitivePackages": [
+ package(
+ "Example.Transitive",
+ "High",
+ "https://github.com/advisories/GHSA-high",
+ )
+ ],
+ }
+ ],
+ }
+ )
+ )
+
+ self.assertEqual(1, result.returncode, result.stderr)
+ self.assertIn("Direct", result.stdout)
+ self.assertIn("Transitive", result.stdout)
+ self.assertIn("2 High/Critical finding(s)", result.stdout)
+
+ def test_incomplete_report_fails_closed(self) -> None:
+ result = self.run_gate(
+ {
+ "version": 1,
+ "parameters": "--vulnerable",
+ "sources": [NUGET_AUDIT_SOURCE],
+ "projects": [{"path": "Product.csproj"}],
+ }
+ )
+
+ self.assertEqual(2, result.returncode)
+ self.assertIn("was not generated with --include-transitive", result.stderr)
+
+ def test_report_without_advisory_capable_source_fails_closed(self) -> None:
+ payload = report({"path": "Product.csproj"})
+ payload["sources"] = ["/private/local-feed"]
+
+ result = self.run_gate(payload)
+
+ self.assertEqual(2, result.returncode)
+ self.assertIn("did not query the required advisory source", result.stderr)
+
+ def test_listed_package_without_vulnerability_records_fails_closed(self) -> None:
+ result = self.run_gate(
+ report(
+ {
+ "path": "Product.csproj",
+ "frameworks": [
+ {
+ "framework": "net8.0",
+ "topLevelPackages": [
+ {
+ "id": "Malformed.Package",
+ "resolvedVersion": "1.2.3",
+ }
+ ],
+ }
+ ],
+ }
+ )
+ )
+
+ self.assertEqual(2, result.returncode)
+ self.assertIn("does not contain any vulnerability records", result.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()