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
7 changes: 1 addition & 6 deletions .github/workflows/msdo-breach-monitor.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions .github/workflows/msdo-breach-monitor.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ Monitor for supply chain security incidents affecting any tool in the MSDO toolc

The `toolchain-version-probe` workflow runs weekly, installs every tool through the real MSDO CLI, and records exactly which package version was resolved into `.github/toolchain-versions.json`. These are the versions MSDO users actually download — not registry "latest", but the version pinned in MSDO's `.gdntool` configs.

**Read the file from this repository:**
**Read the file from this repository (the probe pushes to a dedicated branch to avoid branch protection on main):**
```
GET https://api.github.com/repos/microsoft/security-devops-action/contents/.github/toolchain-versions.json
GET https://api.github.com/repos/microsoft/security-devops-action/contents/.github/toolchain-versions.json?ref=bot/toolchain-versions
```
Decode the base64 `content` field. The `tools` object maps each tool name to its resolved version. The `generated_at` field tells you when the probe last ran.

Expand Down
98 changes: 51 additions & 47 deletions .github/workflows/toolchain-version-probe.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ name: MSDO Toolchain Version Probe
# Runs MSDO to install tools as a side effect, then scrapes the install
# directories to record exact resolved versions into toolchain-versions.json.
# The breach monitor reads this file instead of guessing "latest" from registries.
#
# Guardian installs all tool wrappers as NuGet packages into:
# /home/runner/work/_msdo/packages/nuget/{PackageName}.{version}/
# ESLint is installed via npm into:
# /home/runner/work/_msdo/packages/node_modules/eslint/
# Package names confirmed from run 23433052319.

on:
schedule:
Expand All @@ -20,11 +26,9 @@ jobs:
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

# Run MSDO so it downloads and installs all tool binaries into .gdn/i/.
# Scan may find nothing (no real targets) — that is fine. We only care
# about the side effect: tool packages installed in .gdn/i/{type}/.
# Run MSDO — scan may find nothing (no real targets), that's fine.
# Side effect: Guardian downloads all tool packages into _msdo/packages/nuget/.
- name: Install MSDO tools
id: msdo
uses: microsoft/security-devops-action@main
continue-on-error: true
with:
Expand All @@ -35,73 +39,71 @@ jobs:
python3 - <<'PYEOF'
import os, json, re, pathlib, datetime

# MSDO installs packages into .gdn/i/{type}/{PackageName}.{version}/
# DotNetToolClient.cs:244 → nuget dirs are PackageName.Version
# ZipClient / Pip3Client → same pattern via PackageInstaller
GDN_I = pathlib.Path('.gdn/i')
NUGET_DIR = pathlib.Path('/home/runner/work/_msdo/packages/nuget')
NPM_DIR = pathlib.Path('/home/runner/work/_msdo/packages/node_modules')

VER_PAT = re.compile(r'^(.+?)\.(v?\d+\.\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?)$', re.IGNORECASE)

# Map installed package names → canonical tool names used in our inventory.
# Keys are lowercase. Add new entries after the first probe run reveals
# the exact package names for terrascan, trivy, eslint, bandit, checkov.
# Guardian NuGet wrapper package names → canonical tool names.
# Confirmed from run 23433052319 (_msdo/packages/nuget/ directory listing).
PKG_TO_TOOL = {
# NuGet
'microsoft.codeanalysis.binskim': 'binskim',
'microsoft.azure.templates.analyzer': 'templateanalyzer',
# pip (package name == tool name for these)
'bandit': 'bandit',
'checkov': 'checkov',
# npm
'eslint': 'eslint',
# zip / GitHub releases (names TBD from first run — check raw_dirs)
'trivy': 'trivy',
'terrascan': 'terrascan',
'microsoft.guardian.banditredist_linux_amd64': 'bandit',
'microsoft.codeanalysis.binskim': 'binskim',
'microsoft.guardian.checkovredist_linux_amd64': 'checkov',
'azure.templates.analyzer.commandline.linux-x64': 'templateanalyzer',
'microsoft.guardian.terrascanredist_linux_amd64': 'terrascan',
'microsoft.guardian.trivyredist_linux_amd64': 'trivy',
}

# Internal CLI package — skip in output
CLI_PKGS = {
# Internal packages — skip
SKIP_PKGS = {
'microsoft.security.devops.cli',
'microsoft.security.devops.cli.linux-x64',
'microsoft.security.devops.cli.linux-arm64',
'microsoft.security.devops.cli.win-x64',
'microsoft.security.devops.policy.names',
'microsoft.security.devops.policy.github',
}

tools = {}
raw_dirs = {}

for pkg_type in ('nuget', 'pip', 'npm', 'zip'):
type_dir = GDN_I / pkg_type
if not type_dir.exists():
continue
entries = sorted(d.name for d in type_dir.iterdir() if d.is_dir())
raw_dirs[pkg_type] = entries
raw_dirs = []

if NUGET_DIR.exists():
entries = sorted(d.name for d in NUGET_DIR.iterdir() if d.is_dir())
raw_dirs = entries
for name in entries:
m = VER_PAT.match(name)
if not m:
continue
pkg_lower = m.group(1).lower()
version = m.group(2)
if pkg_lower in CLI_PKGS:
if pkg_lower in SKIP_PKGS:
continue
canonical = PKG_TO_TOOL.get(pkg_lower)
if canonical is None:
continue
tools[canonical] = version
if canonical:
tools[canonical] = version

output = {
'generated_at': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'msdo_cli_version': os.environ.get('MSDO_INSTALLEDVERSION', 'unknown'),
'tools': tools,
'raw_dirs': raw_dirs,
}
# ESLint: installed via npm, read version from package.json
eslint_pkg = NPM_DIR / 'eslint' / 'package.json'
if eslint_pkg.exists():
tools['eslint'] = json.loads(eslint_pkg.read_text())['version']

print('raw_dirs:', raw_dirs)
print('resolved:', tools)

expected = set(PKG_TO_TOOL.values())
missing = expected - set(tools.keys())
if not tools:
raise SystemExit('ERROR: no tool versions resolved — .gdn/i/ may be empty. Aborting to avoid poisoning toolchain-versions.json.')
raise SystemExit('ERROR: no versions resolved — _msdo/packages/nuget/ empty or missing. Aborting.')

missing = (set(PKG_TO_TOOL.values()) | {'eslint'}) - set(tools.keys())
if missing:
print(f'WARNING: expected tools not found in install dirs: {sorted(missing)}')
print(f'WARNING: expected tools not found: {sorted(missing)}')

output = {
'generated_at': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'msdo_cli_version': os.environ.get('MSDO_INSTALLEDVERSION', 'unknown'),
'tools': tools,
'raw_dirs': raw_dirs,
}

out = pathlib.Path('.github/toolchain-versions.json')
out.parent.mkdir(parents=True, exist_ok=True)
Expand All @@ -118,5 +120,7 @@ jobs:
echo "toolchain-versions.json unchanged — nothing to commit"
else
git commit -m "chore(ci): update toolchain-versions.json [skip ci]"
git push
# Push to dedicated unprotected branch — main has branch protection
# requiring PRs. The breach monitor reads from this branch via API.
git push origin HEAD:bot/toolchain-versions --force
fi
Loading