diff --git a/.github/workflows/security-vulnerability-scan.yml b/.github/workflows/security-vulnerability-scan.yml
new file mode 100644
index 00000000000..b01ccc70549
--- /dev/null
+++ b/.github/workflows/security-vulnerability-scan.yml
@@ -0,0 +1,544 @@
+# This workflow runs weekly to scan all NuGet dependencies (direct and transitive)
+# in the dotnet/efcore repository for known security vulnerabilities using the
+# dotnet CLI's built-in audit support. When vulnerabilities are found it:
+# 1. Queries the GitHub Security Advisory API for fixed versions.
+# 2. Attempts to apply fixes by updating versions in Directory.Packages.props
+# and eng/Versions.props, skipping packages that are managed by Maestro
+# dependency flow (eng/Version.Details.xml). Transitive vulnerabilities are
+# addressed by pinning the safe version via CentralPackageTransitivePinning.
+# 3. Opens a pull request with any applied fixes.
+# 4. Creates or updates a tracking GitHub issue with the full vulnerability report.
+
+name: Security Vulnerability Scan
+
+on:
+ schedule:
+ - cron: '0 9 * * 1' # Every Monday at 09:00 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+
+jobs:
+ scan-and-fix:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Restore .NET SDK
+ run: ./restore.sh
+ continue-on-error: true
+
+ - name: Configure .NET environment
+ run: |
+ echo "DOTNET_ROOT=$PWD/.dotnet/" >> "$GITHUB_ENV"
+ echo "$PWD/.dotnet/" >> "$GITHUB_PATH"
+
+ - name: Add nuget.org package source
+ # The repo NuGet.config uses and only lists Azure DevOps feeds.
+ # Adding nuget.org here ensures vulnerability metadata (and public packages)
+ # are available for the scan.
+ run: |
+ dotnet nuget add source "https://api.nuget.org/v3/index.json" \
+ --name "nuget.org" --configfile "./NuGet.config" || true
+
+ - name: Restore NuGet packages
+ run: |
+ for slnf in *.slnf; do
+ echo "Restoring $slnf..."
+ dotnet restore "$slnf" --ignore-failed-sources || true
+ done
+
+ - name: Scan for vulnerable packages
+ run: |
+ echo '{"projects":[]}' > vuln-report.json
+ for slnf in *.slnf; do
+ echo "Scanning $slnf..."
+ dotnet list "$slnf" package --vulnerable --include-transitive \
+ --format json 2>/dev/null \
+ > /tmp/scan-slnf.json \
+ || echo '{"projects":[]}' > /tmp/scan-slnf.json
+ jq -s '{"projects": (.[0].projects + (.[1].projects // []))}' \
+ vuln-report.json /tmp/scan-slnf.json > /tmp/merged.json \
+ && mv /tmp/merged.json vuln-report.json || true
+ done
+ echo "--- Vulnerability report ---"
+ cat vuln-report.json
+
+ - name: Process vulnerabilities and create PR / issue
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const fs = require('fs');
+
+ // ─── 1. Parse the vulnerability report ─────────────────────────────────────
+ let vulnData;
+ try {
+ vulnData = JSON.parse(fs.readFileSync('vuln-report.json', 'utf8'));
+ } catch (e) {
+ core.warning(`Could not parse vuln-report.json: ${e.message}`);
+ return;
+ }
+
+ // Collect unique vulnerable packages across all projects/frameworks.
+ // key: package id (lowercase)
+ // value: { id, resolvedVersion, isTransitive, advisories: [{url, severity}] }
+ const vulnMap = new Map();
+
+ function collectVuln(pkg, isTransitive) {
+ if (!pkg.vulnerabilities || pkg.vulnerabilities.length === 0) return;
+ const key = pkg.id.toLowerCase();
+ if (!vulnMap.has(key)) {
+ vulnMap.set(key, {
+ id: pkg.id,
+ resolvedVersion: pkg.resolvedVersion,
+ isTransitive,
+ advisories: []
+ });
+ }
+ const entry = vulnMap.get(key);
+ for (const v of pkg.vulnerabilities) {
+ if (!entry.advisories.some(a => a.url === v.advisoryurl)) {
+ entry.advisories.push({ url: v.advisoryurl, severity: v.severity });
+ }
+ }
+ }
+
+ for (const project of (vulnData.projects ?? [])) {
+ for (const framework of (project.frameworks ?? [])) {
+ for (const pkg of (framework.topLevelPackages ?? [])) collectVuln(pkg, false);
+ for (const pkg of (framework.transitivePackages ?? [])) collectVuln(pkg, true);
+ }
+ }
+
+ const scanDate = new Date().toISOString().split('T')[0];
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const issueLabel = 'CG-alerts-scan';
+
+ // ─── Helper: ensure tracking label exists ────────────────────────────────
+ async function ensureLabel() {
+ try {
+ await github.rest.issues.getLabel({ owner, repo, name: issueLabel });
+ } catch (e) {
+ if (e.status === 404) {
+ await github.rest.issues.createLabel({
+ owner, repo,
+ name: issueLabel,
+ color: 'ee0701',
+ description: 'Tracks security vulnerabilities found in NuGet dependencies'
+ });
+ }
+ }
+ }
+
+ // ─── Helper: find the open tracking issue ────────────────────────────────
+ async function findOpenTrackingIssue() {
+ const issues = await github.paginate(github.rest.issues.listForRepo, {
+ owner, repo, state: 'open', labels: issueLabel, per_page: 100
+ });
+ return issues.find(i => i.title.startsWith('[Security] Vulnerable NuGet'));
+ }
+
+ // ─── Handle "no vulnerabilities" case ────────────────────────────────────
+ if (vulnMap.size === 0) {
+ console.log('No vulnerable packages found.');
+ await ensureLabel();
+ const existing = await findOpenTrackingIssue();
+ if (existing) {
+ await github.rest.issues.createComment({
+ owner, repo, issue_number: existing.number,
+ body: `✅ Security scan on ${scanDate} found no vulnerable packages. Closing this issue.`
+ });
+ await github.rest.issues.update({
+ owner, repo, issue_number: existing.number, state: 'closed'
+ });
+ console.log(`Closed existing issue #${existing.number}`);
+ }
+ return;
+ }
+
+ console.log(`Found ${vulnMap.size} vulnerable package(s)`);
+
+ // ─── 2. Load Maestro-managed packages (must not be auto-updated) ─────────
+ const maestroPackages = new Set();
+ try {
+ const xml = fs.readFileSync('eng/Version.Details.xml', 'utf8');
+ for (const m of xml.matchAll(/Name="([^"]+)"/g)) {
+ maestroPackages.add(m[1].toLowerCase());
+ }
+ } catch (e) {
+ core.warning(`Could not read Version.Details.xml: ${e.message}`);
+ }
+
+ // ─── 3. Query GitHub Advisory API for fix versions ────────────────────────
+ async function getAdvisoryInfo(advisoryUrl) {
+ const m = advisoryUrl.match(/advisories\/(GHSA-[\w-]+)/i);
+ if (!m) return null;
+ const ghsaId = m[1].toUpperCase();
+ try {
+ const result = await github.graphql(`
+ query($ghsaId: String!) {
+ securityAdvisory(ghsaId: $ghsaId) {
+ summary
+ severity
+ vulnerabilities(first: 20, ecosystem: NUGET) {
+ nodes {
+ package { name }
+ firstPatchedVersion { identifier }
+ vulnerableVersionRange
+ }
+ }
+ }
+ }
+ `, { ghsaId });
+ return result.securityAdvisory;
+ } catch (e) {
+ core.warning(`Could not look up ${ghsaId}: ${e.message}`);
+ return null;
+ }
+ }
+
+ // Build advisory info map and fix version map per package
+ const fixVersionMap = new Map(); // key -> fixVersion string | null
+ const advisoryInfoMap = new Map(); // key -> { summary, severity, advisoryUrls }
+
+ for (const [key, entry] of vulnMap) {
+ let fixVersion = null;
+ let summary = '';
+ let severity = entry.advisories[0]?.severity ?? '';
+
+ for (const adv of entry.advisories) {
+ const info = await getAdvisoryInfo(adv.url);
+ if (!info) continue;
+ if (!summary) summary = info.summary;
+ if (!severity) severity = info.severity;
+ for (const vuln of (info.vulnerabilities?.nodes ?? [])) {
+ if (vuln.package.name.toLowerCase() === key && vuln.firstPatchedVersion) {
+ fixVersion = vuln.firstPatchedVersion.identifier;
+ break;
+ }
+ }
+ if (fixVersion) break;
+ }
+
+ fixVersionMap.set(key, fixVersion);
+ advisoryInfoMap.set(key, {
+ summary,
+ severity,
+ advisoryUrls: entry.advisories.map(a => a.url)
+ });
+ }
+
+ // ─── 4. Load version files and apply fixes ────────────────────────────────
+ function escapeRegex(s) {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ }
+
+ // Locate a package entry in a Directory.Packages.props file.
+ // Returns { found, propertyName, version } where propertyName is set
+ // when the version is an MSBuild property reference like $(Foo).
+ function findPackageEntry(content, packageId) {
+ const re = new RegExp(
+ `<(?:PackageVersion|GlobalPackageReference)\\s+Include="${escapeRegex(packageId)}"\\s+Version="([^"]*)"`,
+ 'i'
+ );
+ const m = content.match(re);
+ if (!m) return { found: false, propertyName: null, version: null };
+ const val = m[1];
+ const propMatch = val.match(/^\$\((\w+)\)$/);
+ return { found: true, propertyName: propMatch ? propMatch[1] : null, version: propMatch ? null : val };
+ }
+
+ // Update a hardcoded version in a PackageVersion or GlobalPackageReference entry.
+ function updateHardcodedVersion(content, packageId, newVersion) {
+ const re = new RegExp(
+ `(<(?:PackageVersion|GlobalPackageReference)\\s+Include="${escapeRegex(packageId)}"\\s+Version=")[^"]*("\\s*/?>)`,
+ 'gi'
+ );
+ return content.replace(re, `$1${newVersion}$2`);
+ }
+
+ // Update a property value in eng/Versions.props.
+ // Returns the updated content, or null if the property was not found.
+ function updatePropertyValue(content, propName, newVersion) {
+ const pattern = `(<${escapeRegex(propName)}>)[^<]*(\\s*${escapeRegex(propName)}>)`;
+ if (!new RegExp(pattern).test(content)) return null;
+ return content.replace(new RegExp(pattern, 'g'), `$1${newVersion}$2`);
+ }
+
+ // Add a PackageVersion entry for a transitive pin (uses CentralPackageTransitivePinning).
+ function addTransitivePin(content, packageId, version) {
+ const insertPoint = content.lastIndexOf('');
+ if (insertPoint === -1) return null;
+ const entry = ` \n`;
+ return content.slice(0, insertPoint) + entry + content.slice(insertPoint);
+ }
+
+ let rootProps = fs.readFileSync('Directory.Packages.props', 'utf8');
+ let versProps = fs.readFileSync('eng/Versions.props', 'utf8');
+ let testProps = fs.existsSync('test/Directory.Packages.props')
+ ? fs.readFileSync('test/Directory.Packages.props', 'utf8')
+ : null;
+
+ const appliedFixes = [];
+ const manualFixes = [];
+
+ for (const [key, entry] of vulnMap) {
+ const fixVersion = fixVersionMap.get(key);
+ const advInfo = advisoryInfoMap.get(key);
+
+ if (maestroPackages.has(key)) {
+ console.log(`${entry.id}: Maestro-managed — skipping auto-fix`);
+ manualFixes.push({ entry, fixVersion, advInfo, reason: 'Maestro-managed (update via dependency flow)' });
+ continue;
+ }
+
+ if (!fixVersion) {
+ console.log(`${entry.id}: No fix version available in advisory database`);
+ manualFixes.push({ entry, fixVersion: null, advInfo, reason: 'No fix version found in advisory database' });
+ continue;
+ }
+
+ let fixed = false;
+
+ // Check root Directory.Packages.props
+ const rootFound = findPackageEntry(rootProps, entry.id);
+ if (rootFound.found) {
+ if (rootFound.propertyName) {
+ const updated = updatePropertyValue(versProps, rootFound.propertyName, fixVersion);
+ if (updated) {
+ versProps = updated;
+ console.log(`${entry.id}: Updated $(${rootFound.propertyName}) → ${fixVersion} in eng/Versions.props`);
+ appliedFixes.push({ entry, fixVersion, advInfo, location: `eng/Versions.props ($(${rootFound.propertyName}))` });
+ fixed = true;
+ } else {
+ // Property not in eng/Versions.props — likely managed externally
+ console.log(`${entry.id}: $(${rootFound.propertyName}) not in eng/Versions.props — skipping`);
+ manualFixes.push({ entry, fixVersion, advInfo, reason: `Property $(${rootFound.propertyName}) is managed externally` });
+ fixed = true;
+ }
+ } else if (rootFound.version !== null) {
+ rootProps = updateHardcodedVersion(rootProps, entry.id, fixVersion);
+ console.log(`${entry.id}: Updated ${rootFound.version} → ${fixVersion} in Directory.Packages.props`);
+ appliedFixes.push({ entry, fixVersion, advInfo, location: 'Directory.Packages.props' });
+ fixed = true;
+ }
+ }
+
+ // Check test/Directory.Packages.props
+ if (!fixed && testProps) {
+ const testFound = findPackageEntry(testProps, entry.id);
+ if (testFound.found) {
+ if (testFound.propertyName) {
+ const updated = updatePropertyValue(versProps, testFound.propertyName, fixVersion);
+ if (updated) {
+ versProps = updated;
+ console.log(`${entry.id}: Updated $(${testFound.propertyName}) → ${fixVersion} in eng/Versions.props`);
+ appliedFixes.push({ entry, fixVersion, advInfo, location: `eng/Versions.props ($(${testFound.propertyName}))` });
+ fixed = true;
+ } else {
+ manualFixes.push({ entry, fixVersion, advInfo, reason: `Property $(${testFound.propertyName}) is managed externally` });
+ fixed = true;
+ }
+ } else if (testFound.version !== null) {
+ testProps = updateHardcodedVersion(testProps, entry.id, fixVersion);
+ console.log(`${entry.id}: Updated ${testFound.version} → ${fixVersion} in test/Directory.Packages.props`);
+ appliedFixes.push({ entry, fixVersion, advInfo, location: 'test/Directory.Packages.props' });
+ fixed = true;
+ }
+ }
+ }
+
+ // Not found in any props — add a transitive pin to root Directory.Packages.props
+ if (!fixed) {
+ const updated = addTransitivePin(rootProps, entry.id, fixVersion);
+ if (updated) {
+ rootProps = updated;
+ console.log(`${entry.id}: Added transitive pin at ${fixVersion} in Directory.Packages.props`);
+ appliedFixes.push({ entry, fixVersion, advInfo, location: 'Directory.Packages.props (transitive pin)' });
+ fixed = true;
+ } else {
+ manualFixes.push({ entry, fixVersion, advInfo, reason: 'Package not found in version management files' });
+ }
+ }
+ }
+
+ // ─── 5. Commit changes and open a PR (if any fixes were applied) ──────────
+ const hasWrittenFixes = appliedFixes.some(f => f.location && !f.location.includes('externally'));
+ let prNumber = null;
+
+ if (hasWrittenFixes) {
+ fs.writeFileSync('Directory.Packages.props', rootProps);
+ fs.writeFileSync('eng/Versions.props', versProps);
+ if (testProps !== null) fs.writeFileSync('test/Directory.Packages.props', testProps);
+
+ const branchName = `cg-alerts/vuln-fix-${scanDate}`;
+
+ await exec.exec('git', ['config', 'user.name', 'github-actions[bot]']);
+ await exec.exec('git', ['config', 'user.email', 'github-actions[bot]@users.noreply.github.com']);
+
+ // Check whether the branch already exists on the remote
+ let branchExists = false;
+ try {
+ const exitCode = await exec.exec(
+ 'git', ['ls-remote', '--exit-code', '--heads', 'origin', branchName],
+ { ignoreReturnCode: true }
+ );
+ branchExists = exitCode === 0;
+ } catch (e) {
+ core.warning(`Failed to check if branch exists: ${e.message}`);
+ }
+
+ if (branchExists) {
+ await exec.exec('git', ['fetch', 'origin', branchName]);
+ await exec.exec('git', ['checkout', branchName]);
+ // Re-apply the in-memory changes (already written to disk above)
+ } else {
+ await exec.exec('git', ['checkout', '-b', branchName]);
+ }
+
+ const filesToAdd = ['Directory.Packages.props', 'eng/Versions.props'];
+ if (testProps !== null) filesToAdd.push('test/Directory.Packages.props');
+ await exec.exec('git', ['add', ...filesToAdd]);
+
+ // Only commit if there are staged changes
+ let hasStagedChanges = false;
+ try {
+ const exitCode = await exec.exec(
+ 'git', ['diff', '--cached', '--quiet'], { ignoreReturnCode: true }
+ );
+ hasStagedChanges = exitCode !== 0;
+ } catch (e) {
+ core.warning(`Failed to check for staged changes: ${e.message}`);
+ hasStagedChanges = false;
+ }
+
+ if (hasStagedChanges) {
+ await exec.exec('git', ['commit', '-m',
+ `fix: update vulnerable NuGet packages (${scanDate})\n\nAuto-fix for ${appliedFixes.length} vulnerable package(s) found by weekly security scan.`
+ ]);
+ // Force-push is intentional: this branch is owned exclusively by the
+ // automated scan workflow and may be rewritten on each weekly run.
+ await exec.exec('git', ['push', 'origin', `HEAD:refs/heads/${branchName}`, '--force']);
+ }
+
+ // Create or find an existing PR for this branch
+ const existingPRs = await github.paginate(github.rest.pulls.list, {
+ owner, repo, state: 'open', head: `${owner}:${branchName}`, per_page: 100
+ });
+
+ if (existingPRs.length > 0) {
+ prNumber = existingPRs[0].number;
+ console.log(`PR #${prNumber} already exists for branch ${branchName}`);
+ } else {
+ const prBody = buildPrBody(appliedFixes, manualFixes, scanDate);
+ const { data: pr } = await github.rest.pulls.create({
+ owner, repo,
+ title: `[Security] Update vulnerable NuGet packages (${scanDate})`,
+ head: branchName,
+ base: 'main',
+ body: prBody
+ });
+ prNumber = pr.number;
+ console.log(`Created PR #${prNumber}`);
+ }
+ }
+
+ // ─── 6. Create or update the tracking issue ──────────────────────────────
+ await ensureLabel();
+ const issueBody = buildIssueBody(vulnMap, appliedFixes, manualFixes, prNumber, scanDate);
+ const existingIssue = await findOpenTrackingIssue();
+
+ if (existingIssue) {
+ await github.rest.issues.createComment({
+ owner, repo, issue_number: existingIssue.number, body: issueBody
+ });
+ console.log(`Updated existing issue #${existingIssue.number}`);
+ } else {
+ const { data: issue } = await github.rest.issues.create({
+ owner, repo,
+ title: `[Security] Vulnerable NuGet dependencies detected - ${scanDate}`,
+ body: issueBody,
+ labels: [issueLabel]
+ });
+ console.log(`Created issue #${issue.number}`);
+ }
+
+ // ─── Helpers ─────────────────────────────────────────────────────────────
+ function advisoryLinks(urls) {
+ return urls
+ .map(u => `[${u.match(/GHSA-[\w-]+/i)?.[0] ?? 'advisory'}](${u})`)
+ .join(', ');
+ }
+
+ function buildPrBody(fixes, manuals, date) {
+ const lines = [
+ `## Security vulnerability fixes (${date})`,
+ '',
+ 'This PR was automatically generated by the [weekly security vulnerability scan workflow](/.github/workflows/security-vulnerability-scan.yml).',
+ '',
+ '### Fixed packages',
+ '',
+ '| Package | From | To | Location | Advisory |',
+ '|---------|------|----|----------|----------|'
+ ];
+ for (const f of fixes) {
+ lines.push(`| \`${f.entry.id}\` | ${f.entry.resolvedVersion} | ${f.fixVersion} | ${f.location} | ${advisoryLinks(f.advInfo.advisoryUrls)} |`);
+ }
+ if (manuals.length > 0) {
+ lines.push(
+ '',
+ '### Packages requiring manual review',
+ '',
+ '| Package | Current | Fix | Reason | Advisory |',
+ '|---------|---------|-----|--------|----------|'
+ );
+ for (const m of manuals) {
+ lines.push(`| \`${m.entry.id}\` | ${m.entry.resolvedVersion} | ${m.fixVersion ?? 'N/A'} | ${m.reason} | ${advisoryLinks(m.advInfo?.advisoryUrls ?? [])} |`);
+ }
+ }
+ return lines.join('\n');
+ }
+
+ function buildIssueBody(vulnMap, fixes, manuals, prNum, date) {
+ const lines = [
+ `## Security vulnerability scan — ${date}`,
+ '',
+ `Found **${vulnMap.size}** vulnerable package(s).`
+ ];
+ if (prNum) {
+ lines.push('', `🔧 **PR #${prNum} has been opened with automatic fixes.**`);
+ }
+ if (fixes.length > 0) {
+ lines.push(
+ '',
+ '### ✅ Auto-fixed',
+ '',
+ '| Package | Type | Severity | From | To | Advisory |',
+ '|---------|------|----------|------|----|----------|'
+ );
+ for (const f of fixes) {
+ const type = f.entry.isTransitive ? 'Transitive' : 'Direct';
+ lines.push(`| \`${f.entry.id}\` | ${type} | ${f.advInfo.severity || '?'} | ${f.entry.resolvedVersion} | ${f.fixVersion} | ${advisoryLinks(f.advInfo.advisoryUrls)} |`);
+ }
+ }
+ if (manuals.length > 0) {
+ lines.push(
+ '',
+ '### ⚠️ Requires manual action',
+ '',
+ '| Package | Type | Severity | Current | Fix | Reason | Advisory |',
+ '|---------|------|----------|---------|-----|--------|----------|'
+ );
+ for (const m of manuals) {
+ const type = m.entry.isTransitive ? 'Transitive' : 'Direct';
+ lines.push(`| \`${m.entry.id}\` | ${type} | ${m.advInfo?.severity || '?'} | ${m.entry.resolvedVersion} | ${m.fixVersion ?? 'N/A'} | ${m.reason} | ${advisoryLinks(m.advInfo?.advisoryUrls ?? [])} |`);
+ }
+ }
+ lines.push('', '---', '_Generated by the [security vulnerability scan workflow](/.github/workflows/security-vulnerability-scan.yml)_');
+ return lines.join('\n');
+ }