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
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ updates:
schedule:
interval: "weekly"
open-pull-requests-limit: 10

- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
22 changes: 22 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,28 @@ permissions:
contents: read

jobs:
javascript-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version-file: .node-version
cache: npm
cache-dependency-path: package-lock.json

- name: Restore npm dependencies
run: npm ci

- name: Run JavaScript tests
run: npm run test:js

- name: Audit npm dependencies
run: npm run audit:high

build:
runs-on: ubuntu-latest
steps:
Expand Down
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24.18.0
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

#### Added

- **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`.

#### Changed
Expand Down Expand Up @@ -1676,6 +1678,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

#### 追加

- **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`。

#### 変更
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,45 @@ public void PackageJson_MetadataMatchesRepository()
Assert.Contains("nildiff", packageJson.GetProperty("description").GetString(), StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Verifies that CI restores the locked npm dependency graph, runs every Jest test, and blocks high-severity audit findings on a pinned Node.js version.
/// CI が固定 Node.js バージョンで npm のロック済み依存グラフを復元し、全 Jest テストを実行して High 以上の監査検出をブロックすることを検証します。
/// </summary>
[Fact]
public void DotNetWorkflow_RunsPinnedJavaScriptTestsAndAuditGate()
{
var workflow = File.ReadAllText(GetRepositoryFilePath(".github", "workflows", "dotnet.yml"));
var nodeVersion = File.ReadAllText(GetRepositoryFilePath(".node-version")).Trim();
var packageJson = JsonDocument.Parse(File.ReadAllText(GetRepositoryFilePath("package.json"))).RootElement;
var auditGate = File.ReadAllText(GetRepositoryFilePath("scripts", "npm-audit-gate.js"));
var auditException = JsonDocument.Parse(
File.ReadAllText(GetRepositoryFilePath("npm-audit-exceptions.json")))
.RootElement
.GetProperty("exceptions")[0];

Assert.Equal("24.18.0", nodeVersion);
Assert.Contains("javascript-tests:", workflow, StringComparison.Ordinal);
Assert.Contains("uses: actions/setup-node@v6", workflow, StringComparison.Ordinal);
Assert.Contains("node-version-file: .node-version", workflow, StringComparison.Ordinal);
Assert.Contains("cache: npm", workflow, StringComparison.Ordinal);
Assert.Contains("cache-dependency-path: package-lock.json", workflow, StringComparison.Ordinal);
Assert.Contains("run: npm ci", workflow, StringComparison.Ordinal);
Assert.Contains("run: npm run test:js", workflow, StringComparison.Ordinal);
Assert.Contains("run: npm run audit:high", workflow, StringComparison.Ordinal);
Assert.Equal(
"node scripts/npm-audit-gate.js",
packageJson.GetProperty("scripts").GetProperty("audit:high").GetString());
Assert.Contains("runAudit(repositoryRoot, ['--omit=dev'])", auditGate, StringComparison.Ordinal);
Assert.Contains("result.expiredExceptions.length > 0", auditGate, StringComparison.Ordinal);
Assert.Equal("GHSA-mh99-v99m-4gvg", auditException.GetProperty("advisory").GetString());
Assert.Equal(1124334, auditException.GetProperty("source").GetInt32());
Assert.Equal("brace-expansion", auditException.GetProperty("package").GetString());
Assert.Equal("high", auditException.GetProperty("severity").GetString());
Assert.Equal("2026-08-31", auditException.GetProperty("expires").GetString());
Assert.False(string.IsNullOrWhiteSpace(auditException.GetProperty("rationale").GetString()));
Assert.False(string.IsNullOrWhiteSpace(auditException.GetProperty("scope").GetString()));
}

/// <summary>
/// Verifies that tagged builds create a GitHub release with attached publish and documentation artifacts.
/// タグ付きビルドが公開・ドキュメント成果物を添付した GitHub リリースを作成することを検証します。
Expand Down Expand Up @@ -398,6 +437,7 @@ public void SecurityAutomation_EnablesCodeQlAndDependabot()

Assert.Contains("package-ecosystem: \"nuget\"", dependabotConfig, StringComparison.Ordinal);
Assert.Contains("package-ecosystem: \"github-actions\"", dependabotConfig, StringComparison.Ordinal);
Assert.Contains("package-ecosystem: \"npm\"", dependabotConfig, StringComparison.Ordinal);
Assert.Contains("interval: \"weekly\"", dependabotConfig, StringComparison.Ordinal);
}

Expand Down
174 changes: 174 additions & 0 deletions JsTests/npm_audit_gate.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
'use strict';

const {
evaluateAuditReport,
hasGateFailures,
} = require('../scripts/npm-audit-gate');

const exception = {
advisory: 'GHSA-mh99-v99m-4gvg',
source: 1124334,
package: 'brace-expansion',
severity: 'high',
rationale: 'Latest supported test tooling has not adopted the patched major version.',
scope: 'Development-only test tooling.',
expires: '2026-08-31',
};

function report(vulnerabilities) {
return {
auditReportVersion: 2,
vulnerabilities,
};
}

function advisory(
source = 1124334,
id = 'GHSA-mh99-v99m-4gvg',
severity = 'high') {
return {
source,
severity,
url: `https://github.com/advisories/${id}`,
};
}

describe('npm audit gate', () => {
test('allows only the exact advisory and its transitive vulnerability chain', () => {
const result = evaluateAuditReport(report({
'brace-expansion': {
severity: 'high',
via: [advisory()],
},
minimatch: {
severity: 'high',
via: ['brace-expansion'],
},
glob: {
severity: 'high',
via: ['minimatch'],
},
}), [exception], new Date('2026-07-25T00:00:00Z'));

expect(result.failures).toEqual([]);
expect(result.allowed.map((finding) => finding.name)).toEqual([
'brace-expansion',
'minimatch',
'glob',
]);
expect(result.usedExceptions).toEqual([exception]);
});

test('ignores non-blocking entries alongside the exact High advisory', () => {
const result = evaluateAuditReport(report({
'brace-expansion': {
severity: 'high',
via: [
advisory(),
advisory(9999999, 'GHSA-xxxx-yyyy-zzzz', 'moderate'),
'moderate-helper',
],
},
'moderate-helper': {
severity: 'moderate',
via: [advisory(9999998, 'GHSA-aaaa-bbbb-cccc', 'moderate')],
},
}), [exception], new Date('2026-07-25T00:00:00Z'));

expect(result.failures).toEqual([]);
expect(result.allowed).toEqual([
{ name: 'brace-expansion', severity: 'high' },
]);
});

test('rejects an unrelated High advisory and packages that depend on it', () => {
const result = evaluateAuditReport(report({
'brace-expansion': {
severity: 'high',
via: [advisory()],
},
minimatch: {
severity: 'high',
via: ['brace-expansion'],
},
'unsafe-package': {
severity: 'high',
via: [advisory(9999999, 'GHSA-xxxx-yyyy-zzzz')],
},
consumer: {
severity: 'critical',
via: ['unsafe-package'],
},
}), [exception], new Date('2026-07-25T00:00:00Z'));

expect(result.failures).toEqual([
{ name: 'unsafe-package', severity: 'high' },
{ name: 'consumer', severity: 'critical' },
]);
});

test('rejects the allowed advisory after its expiry date', () => {
const result = evaluateAuditReport(report({
'brace-expansion': {
severity: 'high',
via: [advisory()],
},
minimatch: {
severity: 'high',
via: ['brace-expansion'],
},
}), [exception], new Date('2026-09-01T00:00:00Z'));

expect(result.failures).toEqual([
{ name: 'brace-expansion', severity: 'high' },
{ name: 'minimatch', severity: 'high' },
]);
expect(result.expiredExceptions).toEqual([exception]);
expect(hasGateFailures(result)).toBe(true);
});

test('fails on an expired exception even after its advisory disappears', () => {
const result = evaluateAuditReport(
report({}),
[exception],
new Date('2026-09-01T00:00:00Z'));

expect(result.failures).toEqual([]);
expect(result.expiredExceptions).toEqual([exception]);
expect(hasGateFailures(result)).toBe(true);
});

test('rejects invalid calendar dates in exception configuration', () => {
expect(() => evaluateAuditReport(
report({}),
[{ ...exception, expires: '2026-99-99' }],
new Date('2026-07-25T00:00:00Z')))
.toThrow('must be a valid calendar date');
});

test('does not apply development exceptions to the production audit', () => {
const productionResult = evaluateAuditReport(report({
'brace-expansion': {
severity: 'high',
via: [advisory()],
},
}), [], new Date('2026-07-25T00:00:00Z'));

expect(productionResult.failures).toEqual([
{ name: 'brace-expansion', severity: 'high' },
]);
expect(hasGateFailures(productionResult)).toBe(true);
});

test('does not block unrelated findings below High severity', () => {
const result = evaluateAuditReport(report({
'informational-package': {
severity: 'moderate',
via: [advisory(9999999, 'GHSA-xxxx-yyyy-zzzz')],
},
}), [exception], new Date('2026-07-25T00:00:00Z'));

expect(result.failures).toEqual([]);
expect(result.allowed).toEqual([]);
});
});
Loading
Loading