Files
Dan Moseley 64464e87e3 Add external reference scanner for skill/agent content (#336)
* Add external reference scanner for skill/agent content

- PowerShell scanner (eng/reference-scanner/scan.ps1) checks for:
  - http:// URLs (blocked except localhost and XML namespace URIs)
  - External <script> tags without SRI integrity attribute
  - Pipe-to-shell patterns (except dotnet install scripts)
  - URLs referencing domains not in .github/known-domains.txt
- CI workflow runs on PRs and pushes touching skill/agent paths
- CONTRIBUTING.md updated with external references policy
- README.md: fixed http:// to https:// for agentskills.io

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Move known-domains.txt to eng/reference-scanner/, fix comments, add ollama.com

- Moved from .github/ to eng/reference-scanner/ (co-located with scanner)
- Updated header comments: errors not informational notices, removed misleading org-scoping language
- Added ollama.com (pre-existing reference in dotnet-ai skill)
- Updated all references in scan.ps1, CI workflow, CONTRIBUTING.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review feedback

- Add ? and # as valid URL terminators in path-scoped domain matching
- Strip # from host extraction in Test-KnownDomain and Test-HttpNotHttps
- Make script/SRI regex patterns case-insensitive
- Emit error finding on file read failure instead of silent skip
- Fix help comment (two levels up, not three)
- Unify CONTRIBUTING.md error language (all checks are CI-blocking)
- Add eng/reference-scanner/** to workflow path triggers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add cdn.jsdelivr.net to known-domains (used by dashboard after #331 adds SRI)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove cdn.jsdelivr.net from known-domains

jsdelivr serves arbitrary npm packages -- keeping it off the allowlist
means any use requires both SRI and reviewer approval (two gates).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Skip domain check for scripts with SRI, fix single-line file handling

Scripts with integrity attributes are secured by hash -- the domain
is irrelevant. This means cdn.jsdelivr.net does not need to be in
known-domains.txt as long as SRI is present.

Also wrap Get-Content in @() to handle single-line files correctly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address round-2 review feedback

- Multi-line <script> tag detection: join file content so SRI attributes
  spanning lines are correctly detected
- Safe relative path: use GetRelativePath() with fallback instead of
  bare Substring that throws for out-of-repo files
- Remove dead notice plumbing: all findings are errors now

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Scan only changed files on PRs, full scan on push to main

PRs scan only changed .md/.html files so pre-existing issues on main
don't block unrelated PRs. Push to main runs the full -All scan.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-12 10:43:19 -06:00

327 lines
11 KiB
PowerShell

<#
.SYNOPSIS
Reference scanner for dotnet/skills.
.DESCRIPTION
Scans skill and agent markdown files for external references and
potentially dangerous patterns. Outputs GitHub Actions annotations
when running in CI, or human-readable text otherwise.
.PARAMETER All
Scan all skill/agent files in the repository.
.PARAMETER Paths
Specific files to scan.
.PARAMETER RepoRoot
Repository root path. Defaults to two levels up from this script.
.EXAMPLE
pwsh eng/reference-scanner/scan.ps1 -All
pwsh eng/reference-scanner/scan.ps1 -Paths plugins/dotnet/skills/foo/SKILL.md
#>
[CmdletBinding()]
param(
[switch]$All,
[string[]]$Paths,
[string]$RepoRoot
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------------------
# Setup
# ---------------------------------------------------------------------------
if (-not $RepoRoot) {
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..' '..')).Path
}
$KnownDomainsFile = Join-Path $PSScriptRoot 'known-domains.txt'
# ---------------------------------------------------------------------------
# Load known domains
# ---------------------------------------------------------------------------
function Get-KnownDomains {
if (-not (Test-Path $KnownDomainsFile)) { return @() }
$domains = @()
foreach ($line in (Get-Content $KnownDomainsFile -Encoding UTF8)) {
$line = $line.Trim()
if ($line -and -not $line.StartsWith('#')) {
$domains += $line.ToLower()
}
}
return $domains
}
# ---------------------------------------------------------------------------
# Domain matching
# ---------------------------------------------------------------------------
function Test-KnownDomain {
param([string]$Url, [string[]]$KnownDomains)
$urlLower = $Url.ToLower()
foreach ($domain in $KnownDomains) {
if ($domain.StartsWith('github.com/')) {
# Org-scoped: require /, ?, #, or end-of-string after the org/repo prefix
if ($urlLower -match "^https?://$([regex]::Escape($domain))([/?#]|$)") {
return $true
}
}
else {
# Extract host from URL
$host_ = ($urlLower -replace '^https?://', '') -replace '[/:\?#].*$', ''
if ($host_ -eq $domain -or $host_.EndsWith(".$domain")) {
return $true
}
}
}
return $false
}
function Test-LocalUrl {
param([string]$Url)
$lower = $Url.ToLower()
# Match localhost, 127.0.0.1, [::1] followed by :, /, or end-of-string
return ($lower -match '^https?://localhost([:/]|$)' -or
$lower -match '^https?://127\.0\.0\.1([:/]|$)' -or
$lower -match '^https?://\[::1\]([:/]|$)')
}
function Test-HttpNotHttps {
param([string]$Url)
$lower = $Url.ToLower()
# Extract host and check against schemas.microsoft.com exactly
$host_ = ($lower -replace '^https?://', '') -replace '[/:\?#].*$', ''
return ($lower.StartsWith('http://') -and
-not (Test-LocalUrl $Url) -and
$host_ -ne 'schemas.microsoft.com' -and
-not $host_.EndsWith('.schemas.microsoft.com'))
}
# ---------------------------------------------------------------------------
# File discovery
# ---------------------------------------------------------------------------
function Get-SkillFiles {
param([string]$Root)
$files = @()
# plugins/**/SKILL.md and plugins/**/*.agent.md
$files += @(Get-ChildItem -Path (Join-Path $Root 'plugins') -Recurse -Filter 'SKILL.md' -ErrorAction SilentlyContinue)
$files += @(Get-ChildItem -Path (Join-Path $Root 'plugins') -Recurse -Filter '*.agent.md' -ErrorAction SilentlyContinue)
# plugins/**/references/*.md
$files += @(Get-ChildItem -Path (Join-Path $Root 'plugins') -Recurse -Directory -Filter 'references' -ErrorAction SilentlyContinue |
ForEach-Object { Get-ChildItem -Path $_.FullName -Filter '*.md' -ErrorAction SilentlyContinue })
# .agents/**/*.md
$agentsDir = Join-Path $Root '.agents'
if (Test-Path $agentsDir) {
$files += @(Get-ChildItem -Path $agentsDir -Recurse -Filter '*.md' -ErrorAction SilentlyContinue)
}
# agentic-workflows/**/*.md
$awDir = Join-Path $Root 'agentic-workflows'
if (Test-Path $awDir) {
$files += @(Get-ChildItem -Path $awDir -Recurse -Filter '*.md' -ErrorAction SilentlyContinue)
}
# eng/**/*.html
$files += @(Get-ChildItem -Path (Join-Path $Root 'eng') -Recurse -Filter '*.html' -ErrorAction SilentlyContinue)
# README.md
$readme = Join-Path $Root 'README.md'
if (Test-Path $readme) { $files += @(Get-Item $readme) }
return ($files | Sort-Object FullName -Unique)
}
# ---------------------------------------------------------------------------
# Scanning
# ---------------------------------------------------------------------------
class RefFinding {
[string]$Path
[int]$LineNum
[string]$Level # "error"
[string]$Code
[string]$Message
}
function Invoke-ScanFile {
param(
[string]$FilePath,
[string]$Root,
[string[]]$KnownDomains
)
$findings = [System.Collections.Generic.List[RefFinding]]::new()
$relPath = $FilePath
try {
$relPath = [System.IO.Path]::GetRelativePath($Root, $FilePath)
}
catch {
if ($FilePath.StartsWith($Root, [System.StringComparison]::OrdinalIgnoreCase)) {
$relPath = $FilePath.Substring($Root.Length)
}
}
$relPath = $relPath.TrimStart('\', '/') -replace '\\', '/'
try {
$lines = @(Get-Content $FilePath -Encoding UTF8)
}
catch {
$errorFinding = [RefFinding]::new()
$errorFinding.Path = $relPath
$errorFinding.LineNum = 0
$errorFinding.Level = 'error'
$errorFinding.Code = 'FILE-READ-ERROR'
$errorFinding.Message = "Failed to read file: $($_.Exception.Message)"
$findings.Add($errorFinding)
return $findings
}
$urlPattern = [regex]'https?://[^\s\)\]\"''<>;]+'
$pipeToShell = [regex]'curl\s[^|]*\|\s*(ba)?sh\b|wget\s[^|]*\|\s*(ba)?sh\b'
$sriIntegrity = [regex]'(?i)integrity\s*='
$externalSrc = [regex]'(?i)src\s*=\s*["\x27]https?://'
# Multi-line <script> tag detection: join file content to catch attributes spanning lines
$fullContent = $lines -join "`n"
$scriptTagMultiLine = [regex]'(?is)<script\s[^>]*src\s*=\s*["\x27][^"\x27]*["\x27][^>]*>'
$sriProtectedUrls = @()
foreach ($m in $scriptTagMultiLine.Matches($fullContent)) {
$tag = $m.Value
$hasSri = $sriIntegrity.IsMatch($tag)
# Find line number from character offset
$tagLineNum = ($fullContent.Substring(0, $m.Index) -split "`n").Count
if ($externalSrc.IsMatch($tag)) {
$scriptUrl = $null
if ($tag -match '(?i)src\s*=\s*["' + "'" + ']([^"' + "'" + ']+)') {
$scriptUrl = $matches[1]
}
if ($hasSri) {
if ($scriptUrl) { $sriProtectedUrls += $scriptUrl }
}
elseif (-not $scriptUrl -or -not (Test-LocalUrl $scriptUrl)) {
$f = [RefFinding]::new()
$f.Path = $relPath; $f.LineNum = $tagLineNum; $f.Level = 'error'
$f.Code = 'SCRIPT-NO-SRI'
$f.Message = 'External script tag without integrity (SRI) attribute'
$findings.Add($f)
}
}
}
# Line-by-line scanning for URLs and pipe-to-shell
for ($i = 0; $i -lt $lines.Count; $i++) {
$line = $lines[$i]
$lineNum = $i + 1
# Pipe-to-shell (except known-safe .NET install scripts)
if ($pipeToShell.IsMatch($line)) {
$allowedPipeUrls = @(
'https://dot.net/v1/dotnet-install.sh',
'https://aka.ms/dotnet-install.sh'
)
$isPipeAllowed = $false
foreach ($allowed in $allowedPipeUrls) {
# Require the URL to end at a word boundary (space, quote, pipe, or end-of-string)
if ($line -match "$([regex]::Escape($allowed))(\s|['""|]|$)") { $isPipeAllowed = $true; break }
}
if (-not $isPipeAllowed) {
$f = [RefFinding]::new()
$f.Path = $relPath; $f.LineNum = $lineNum; $f.Level = 'error'
$f.Code = 'PIPE-TO-SHELL'
$f.Message = 'Pipe-to-shell pattern: content is downloaded and piped directly to a shell interpreter'
$findings.Add($f)
}
}
# All URLs
foreach ($m in $urlPattern.Matches($line)) {
$url = $m.Value.TrimEnd('.', ',', ';', ':', ')', "'", '"')
if (Test-HttpNotHttps $url) {
$f = [RefFinding]::new()
$f.Path = $relPath; $f.LineNum = $lineNum; $f.Level = 'error'
$f.Code = 'HTTP-NOT-HTTPS'
$f.Message = "Insecure http:// URL (use https://): $url"
$findings.Add($f)
}
elseif (-not (Test-KnownDomain $url $KnownDomains) -and -not (Test-LocalUrl $url) -and $url -notin $sriProtectedUrls) {
$f = [RefFinding]::new()
$f.Path = $relPath; $f.LineNum = $lineNum; $f.Level = 'error'
$f.Code = 'EXTERNAL-DOMAIN'
$f.Message = "Domain not in known-domains.txt -- add it to eng/reference-scanner/known-domains.txt in your PR if this reference is intentional: $url"
$findings.Add($f)
}
}
}
return $findings
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
$knownDomains = Get-KnownDomains
# Determine files to scan
if ($All) {
$files = Get-SkillFiles $RepoRoot
}
elseif ($Paths) {
$files = $Paths | Where-Object { Test-Path $_ } | ForEach-Object { Get-Item $_ }
}
elseif ($env:CHANGED_FILES) {
$files = $env:CHANGED_FILES -split "`n" |
Where-Object { $_ -match '\.(md|html)$' -and (Test-Path $_) } |
ForEach-Object { Get-Item $_ }
}
else {
$files = Get-SkillFiles $RepoRoot
}
$allFindings = [System.Collections.Generic.List[RefFinding]]::new()
foreach ($file in $files) {
$results = @(Invoke-ScanFile $file.FullName $RepoRoot $knownDomains)
foreach ($r in $results) {
if ($r) { $allFindings.Add($r) }
}
}
$errorCount = ($allFindings | Measure-Object).Count
$fileCount = ($files | Measure-Object).Count
$isCI = $env:GITHUB_ACTIONS -eq 'true'
if ($isCI) {
foreach ($f in $allFindings) {
Write-Host "::error file=$($f.Path),line=$($f.LineNum)::[$($f.Code)] $($f.Message)"
}
}
else {
if ($errorCount -gt 0) {
Write-Host "`n $errorCount error(s):`n"
foreach ($f in $allFindings) {
Write-Host " $([char]0x274C) $($f.Path):$($f.LineNum) [$($f.Code)] $($f.Message)"
}
}
else {
Write-Host "`n No external reference issues found.`n"
}
}
Write-Host "`n--- Reference scan: $fileCount file(s) scanned, $errorCount error(s) ---"
if ($errorCount -gt 0) { exit 1 } else { exit 0 }