From bd591da7e36f3e01c14fe55c0ee968aa6195068a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:52:34 +0000 Subject: [PATCH 1/2] feat: add weekly security vulnerability scan workflow Agent-Logs-Url: https://github.com/dotnet/efcore/sessions/3c51563c-f551-4d54-a41e-5364529cb733 Co-authored-by: SamMonoRT <46026722+SamMonoRT@users.noreply.github.com> --- .../workflows/security-vulnerability-scan.yml | 544 ++++++++++++++++++ 1 file changed, 544 insertions(+) create mode 100644 .github/workflows/security-vulnerability-scan.yml diff --git a/.github/workflows/security-vulnerability-scan.yml b/.github/workflows/security-vulnerability-scan.yml new file mode 100644 index 00000000000..927ab164886 --- /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 = 'security-vulnerability-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)}>)[^<]*()`; + 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 = `security/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'); + } From ae3748226748dec55d826addc7d7aee862c17835 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:05:54 +0000 Subject: [PATCH 2/2] fix: rename label to CG-alerts-scan and branch to cg-alerts/vuln-fix-date Agent-Logs-Url: https://github.com/dotnet/efcore/sessions/797470d3-f8e4-4ac2-b0b1-45655b720a2f Co-authored-by: SamMonoRT <46026722+SamMonoRT@users.noreply.github.com> --- .github/workflows/security-vulnerability-scan.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-vulnerability-scan.yml b/.github/workflows/security-vulnerability-scan.yml index 927ab164886..b01ccc70549 100644 --- a/.github/workflows/security-vulnerability-scan.yml +++ b/.github/workflows/security-vulnerability-scan.yml @@ -117,7 +117,7 @@ jobs: const scanDate = new Date().toISOString().split('T')[0]; const owner = context.repo.owner; const repo = context.repo.repo; - const issueLabel = 'security-vulnerability-scan'; + const issueLabel = 'CG-alerts-scan'; // ─── Helper: ensure tracking label exists ──────────────────────────────── async function ensureLabel() { @@ -376,7 +376,7 @@ jobs: fs.writeFileSync('eng/Versions.props', versProps); if (testProps !== null) fs.writeFileSync('test/Directory.Packages.props', testProps); - const branchName = `security/vuln-fix-${scanDate}`; + 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']);