diff --git a/.github/skills/github/gh-code-scanning/SKILL.md b/.github/skills/github/gh-code-scanning/SKILL.md index 0da504884..8d1cba0bc 100644 --- a/.github/skills/github/gh-code-scanning/SKILL.md +++ b/.github/skills/github/gh-code-scanning/SKILL.md @@ -3,7 +3,6 @@ name: gh-code-scanning description: 'Retrieves and groups GitHub code scanning alerts by rule and severity using the gh CLI - Brought to you by microsoft/hve-core' license: MIT compatibility: 'Requires pwsh 7+ and gh CLI authenticated with the security_events scope. Bash script requires jq.' -ms.date: 2026-04-21 metadata: authors: "microsoft/hve-core" spec_version: "1.0" @@ -41,12 +40,12 @@ This returns a JSON array of alert groups sorted by occurrence count, descending ## Parameters Reference -| Parameter | Type | Required | Default | Description | -|-----------------|--------|----------|---------|---------------------------------------------------------------------------| -| `-Owner` | String | Yes | | GitHub organization or user that owns the repository | -| `-Repo` | String | Yes | | Repository name | -| `-OutputFormat` | String | No | Table | Output format: agents must always use `Json` for programmatic consumption | -| `-Branch` | String | No | `main` | Branch to scope alert results | +| Parameter | Type | Required | Default | Description | +|-----------------|--------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------| +| `-Owner` | String | Yes | | GitHub organization or user that owns the repository | +| `-Repo` | String | Yes | | Repository name | +| `-OutputFormat` | String | No | Table | Output format: agents must always use `Json` for programmatic consumption; `GroupedJson` is accepted as an alias for `Json` | +| `-Branch` | String | No | `main` | Branch to scope alert results | > These parameters apply to `Get-CodeScanningAlerts.ps1`. For bash script flags including `-s {severity}`, see the Script Reference section below. @@ -108,36 +107,46 @@ Use `-Branch {branch}` to scope to a branch other than `main`. "RuleId": "py/empty-except", "Tool": "CodeQL", "SecuritySeverity": null, + "Severity": "warning", "Count": 23, - "SamplePaths": [ + "AffectedPaths": [ "scripts/collections/Get-CollectionItems.py", "scripts/linting/Validate-MarkdownFrontmatter.py" - ] + ], + "HasFilePaths": true, + "AlertUrl": "https://github.com/microsoft/hve-core/security/code-scanning/42", + "FindingDescription": "'except' clause does nothing but pass and there is no explanatory comment." }, { "RuleDescription": "Code injection", "RuleId": "actions/code-injection/medium", "Tool": "CodeQL", "SecuritySeverity": "medium", + "Severity": "error", "Count": 2, - "SamplePaths": [ + "AffectedPaths": [ ".github/workflows/validate.yml" - ] + ], + "HasFilePaths": true, + "AlertUrl": "https://github.com/microsoft/hve-core/security/code-scanning/17", + "FindingDescription": "Potential code injection in ${{ inputs.version }}, which may be controlled by an external user." }, { "RuleDescription": "Branch-Protection", "RuleId": "BranchProtectionID", "Tool": "Scorecard", "SecuritySeverity": "high", + "Severity": "error", "Count": 1, - "SamplePaths": [ - "no file associated with this alert" - ] + "AffectedPaths": [], + "HasFilePaths": false, + "AlertUrl": "https://github.com/microsoft/hve-core/security/code-scanning/1", + "FindingDescription": "score is 9: branch protection is not maximal on development and all release branches" } ] ``` -`SecuritySeverity` is `null` when the rule has no severity tier assigned. `SamplePaths` is always a JSON array. When an alert has no associated source file (for example, `BranchProtectionID`), the array contains the sentinel string `"no file associated with this alert"`. +`SecuritySeverity` is `null` for code quality rules that have no security classification; `Severity` (the non-security rule severity: `error`, `warning`, `note`, `none`) provides a fallback. `AffectedPaths` is always a JSON array of unique, sorted file paths with sentinel strings filtered out. `HasFilePaths` is `false` and `AffectedPaths` is `[]` when an alert has no associated source file (for example, `BranchProtectionID`). `AlertUrl` links directly to the alert in the GitHub Security tab. `FindingDescription` is the most recent alert message text. ### Get single alert detail @@ -149,11 +158,14 @@ gh api repos/{owner}/{repo}/code-scanning/alerts/{alert_number} ### List affected file paths -Use `-OutputFormat Json` and read the `SamplePaths` field from each rule group. The JSON output includes `RuleDescription`, `RuleId`, `Tool`, `SecuritySeverity`, `Count`, and `SamplePaths` (unique, sorted file paths) per group. +Use `-OutputFormat Json` and read the `AffectedPaths` field from each rule group. The JSON output includes `RuleDescription`, `RuleId`, `Tool`, `SecuritySeverity`, `Severity`, `Count`, `AffectedPaths` (unique, sorted file paths), `HasFilePaths` (boolean: `false` for repo-level rules that have no associated source file), `AlertUrl` (string: direct link to the alert in the GitHub Security tab), and `FindingDescription` (string: most recent alert message text from the analysis tool) per group. ### Key fields -* `rule.security_severity_level`: severity tier: `critical`, `high`, `medium`, or `low` +These are GitHub API response field paths, not output object properties. The grouped output object field names are listed in the JSON output shape section above. + +* `rule.security_severity_level`: security severity tier: `critical`, `high`, `medium`, or `low`; `null` for code quality rules +* `rule.severity`: non-security rule severity: `error`, `warning`, `note`, or `none`; always populated * `rule.id`: rule identifier used for deduplication and cross-referencing * `tool.name`: analysis tool that produced the alert (for example, `CodeQL`) * `most_recent_instance.location.path`: source file path of the most recent alert occurrence @@ -199,12 +211,12 @@ if [[ -z "$existing" ]]; then ## Code Scanning Alert: {rule_description} **Rule:** \`{rule_id}\` -**Severity:** {security_severity} +$([ -n "{severity}" ] && echo "**Severity:** {severity}") **Tool:** {tool} **Affected files:** {count} occurrences -### Sample affected paths -{sample_paths} +### Affected paths +{affected_paths} " fi ``` diff --git a/.github/skills/github/gh-code-scanning/scripts/Get-CodeScanningAlerts.ps1 b/.github/skills/github/gh-code-scanning/scripts/Get-CodeScanningAlerts.ps1 index 575b409aa..960c0bf33 100644 --- a/.github/skills/github/gh-code-scanning/scripts/Get-CodeScanningAlerts.ps1 +++ b/.github/skills/github/gh-code-scanning/scripts/Get-CodeScanningAlerts.ps1 @@ -85,13 +85,23 @@ if ($MyInvocation.InvocationName -ne '.') { $Grouped = $Alerts | Group-Object { $_.rule.description } | ForEach-Object { + $paths = @( + $_.Group | + ForEach-Object { $_.most_recent_instance.location.path } | + Where-Object { $_ -and $_ -notmatch '(?i)no file' } | + Sort-Object -Unique + ) [PSCustomObject]@{ - RuleDescription = $_.Name - RuleId = $_.Group[0].rule.id - Tool = $_.Group[0].tool.name - SecuritySeverity = $_.Group[0].rule.security_severity_level - Count = $_.Count - SamplePaths = @($_.Group | ForEach-Object { $_.most_recent_instance.location.path } | Sort-Object -Unique) + RuleDescription = $_.Name + RuleId = $_.Group[0].rule.id + Tool = $_.Group[0].tool.name + SecuritySeverity = $_.Group[0].rule.security_severity_level + Severity = $_.Group[0].rule.severity + Count = $_.Count + AffectedPaths = $paths + HasFilePaths = ($paths.Count -gt 0) + AlertUrl = $_.Group[0].html_url + FindingDescription = $_.Group[0].most_recent_instance.message.text } } | Sort-Object -Property Count -Descending diff --git a/.github/skills/github/gh-code-scanning/tests/Get-CodeScanningAlerts.Tests.ps1 b/.github/skills/github/gh-code-scanning/tests/Get-CodeScanningAlerts.Tests.ps1 index bb78c2ce7..6dae74664 100644 --- a/.github/skills/github/gh-code-scanning/tests/Get-CodeScanningAlerts.Tests.ps1 +++ b/.github/skills/github/gh-code-scanning/tests/Get-CodeScanningAlerts.Tests.ps1 @@ -84,17 +84,17 @@ Describe 'Get-CodeScanningAlerts' -Tag 'Unit' { $parsed.Count | Should -BeGreaterThan 0 } - It 'Serializes SamplePaths as a JSON array even when only one path exists' { + It 'Serializes AffectedPaths as a JSON array even when only one path exists' { # js/xss has a single occurrence; verify the raw JSON uses bracket notation, # not a bare string (ConvertFrom-Json re-unwraps single-element arrays so # the raw string is the authoritative check) $result = & $script:ScriptPath -Owner 'testorg' -Repo 'testrepo' -OutputFormat Json $rawJson = $result | Out-String - $rawJson | Should -Match '"SamplePaths":\s*\[' + $rawJson | Should -Match '"AffectedPaths":\s*\[' } - It 'Serializes SamplePaths as a JSON array when alert has no associated file path' { + It 'Serializes AffectedPaths as empty array and sets HasFilePaths false when alert has no associated file path' { $noPathJson = '[{"number":10,"rule":{"id":"BranchProtectionID","description":"Branch-Protection","security_severity_level":"high"},"tool":{"name":"Scorecard"},"most_recent_instance":{"location":{"path":"no file associated with this alert"}}}]' ${Function:gh} = { $global:LASTEXITCODE = 0 @@ -102,13 +102,13 @@ Describe 'Get-CodeScanningAlerts' -Tag 'Unit' { }.GetNewClosure() $result = & $script:ScriptPath -Owner 'testorg' -Repo 'testrepo' -OutputFormat Json - $rawJson = $result | Out-String + $parsed = $result | ConvertFrom-Json - $rawJson | Should -Match '"SamplePaths":\s*\[' - $rawJson | Should -Match 'no file associated with this alert' + $parsed[0].AffectedPaths | Should -HaveCount 0 + $parsed[0].HasFilePaths | Should -BeFalse } - It 'Deduplicates and sorts SamplePaths across multiple occurrences of the same rule' { + It 'Deduplicates and sorts AffectedPaths across multiple occurrences of the same rule' { $multiPathJson = '[{"number":1,"rule":{"id":"py/empty-except","description":"Empty except","security_severity_level":null},"tool":{"name":"CodeQL"},"most_recent_instance":{"location":{"path":"scripts/b.py"}}},{"number":2,"rule":{"id":"py/empty-except","description":"Empty except","security_severity_level":null},"tool":{"name":"CodeQL"},"most_recent_instance":{"location":{"path":"scripts/a.py"}}},{"number":3,"rule":{"id":"py/empty-except","description":"Empty except","security_severity_level":null},"tool":{"name":"CodeQL"},"most_recent_instance":{"location":{"path":"scripts/a.py"}}}]' ${Function:gh} = { $global:LASTEXITCODE = 0 @@ -118,9 +118,9 @@ Describe 'Get-CodeScanningAlerts' -Tag 'Unit' { $result = & $script:ScriptPath -Owner 'testorg' -Repo 'testrepo' -OutputFormat Json $parsed = $result | ConvertFrom-Json - $parsed[0].SamplePaths | Should -HaveCount 2 - $parsed[0].SamplePaths[0] | Should -Be 'scripts/a.py' - $parsed[0].SamplePaths[1] | Should -Be 'scripts/b.py' + $parsed[0].AffectedPaths | Should -HaveCount 2 + $parsed[0].AffectedPaths[0] | Should -Be 'scripts/a.py' + $parsed[0].AffectedPaths[1] | Should -Be 'scripts/b.py' } } diff --git a/.github/workflows/create-gh-code-scanning-issues.yml b/.github/workflows/create-gh-code-scanning-issues.yml new file mode 100644 index 000000000..bb7c583f3 --- /dev/null +++ b/.github/workflows/create-gh-code-scanning-issues.yml @@ -0,0 +1,113 @@ +name: Create GitHub Code Scanning Issues + +on: + workflow_call: + inputs: + artifact-name: + description: 'Name of the artifact containing code scanning alerts' + required: false + type: string + default: gh-code-scanning-alerts + +permissions: + issues: write + security-events: read + +jobs: + create-gh-code-scanning-issues: + name: Create GitHub Code Scanning Issues + runs-on: ubuntu-latest + permissions: + issues: write + security-events: read + env: + GH_TOKEN: ${{ github.token }} + OWNER: ${{ github.repository_owner }} + REPO: ${{ github.event.repository.name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + steps: + - name: Download alerts artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.artifact-name }} + + - name: Create backlog issues for new findings + shell: bash + run: | + while IFS= read -r alert; do + RULE_ID=$(echo "$alert" | jq -r '.RuleId') + RULE_DESC=$(echo "$alert" | jq -r '.RuleDescription') + SEVERITY=$(echo "$alert" | jq -r '.SecuritySeverity // .Severity // empty') + TOOL=$(echo "$alert" | jq -r '.Tool') + COUNT=$(echo "$alert" | jq -r '.Count') + HAS_FILE_PATHS=$(echo "$alert" | jq -r '.HasFilePaths') + ALERT_URL=$(echo "$alert" | jq -r '.AlertUrl // ""') + FINDING_DESC=$(echo "$alert" | jq -r '.FindingDescription // ""') + MARKER="automation:security-scan:${RULE_ID}" + if [[ -n "$SEVERITY" ]]; then + ISSUE_TITLE="[Security][${SEVERITY}] ${RULE_DESC}" + else + ISSUE_TITLE="[Security] ${RULE_DESC}" + fi + OCCURRENCE_WORD="$([ "$COUNT" = "1" ] && echo "occurrence" || echo "occurrences")" + DETECTION_DATE=$(date -u '+%Y-%m-%d') + RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + REPO_BASE="https://github.com/${OWNER}/${REPO}/blob/main" + + if [[ "$HAS_FILE_PATHS" == "true" ]]; then + AFFECTED_LIST=$(echo "$alert" | jq -r --arg base "$REPO_BASE" '.AffectedPaths[] | "- [\(.)](\($base + "/" + .))"') + PATHS_SECTION=$'### Affected paths\n\n'"${AFFECTED_LIST}" + else + PATHS_SECTION=$'### Repository configuration finding\n\nThis alert refers to a repository-level configuration setting with no associated source file.' + fi + + existing=$(gh issue list \ + --repo "${OWNER}/${REPO}" \ + --search "\"${MARKER}\" in:body" \ + --state open --json number --jq '.[0].number // empty') + + ISSUE_BODY=" + ## Code Scanning Alert: ${RULE_DESC} + + **Rule:** \`${RULE_ID}\` + $([ -n "${SEVERITY}" ] && echo "**Severity:** ${SEVERITY}") + **Tool:** ${TOOL} + **Occurrences:** ${COUNT} ${OCCURRENCE_WORD} + $([ -n "${ALERT_URL}" ] && echo "**Alert:** ${ALERT_URL}") + + ### What was found + $([ -n "${FINDING_DESC}" ] && echo "${FINDING_DESC}" || echo "See the linked alert for details.") + + ${PATHS_SECTION} + + --- + **Detection Date:** ${DETECTION_DATE} + **Workflow Run:** ${RUN_URL} + + ### Action Required + - [ ] Review the alert and confirm it is not a false positive + - [ ] Remediate or dismiss the finding with a documented reason + - [ ] Close this issue after the fix is merged + " + + if [[ -z "$existing" ]]; then + gh issue create \ + --repo "${OWNER}/${REPO}" \ + --title "${ISSUE_TITLE}" \ + --label "security,automated,needs-triage" \ + --body "${ISSUE_BODY}" + else + gh issue edit "${existing}" \ + --repo "${OWNER}/${REPO}" \ + --title "${ISSUE_TITLE}" \ + --body "${ISSUE_BODY}" + gh issue edit "${existing}" \ + --repo "${OWNER}/${REPO}" \ + --add-label "automated,needs-triage" + gh issue comment "${existing}" \ + --repo "${OWNER}/${REPO}" \ + --body "Weekly scan update: ${COUNT} ${OCCURRENCE_WORD} as of ${DETECTION_DATE}." + fi + done < <(jq -c '.[]' alerts.json) diff --git a/.github/workflows/create-gh-security-scanning-issues.yml b/.github/workflows/create-gh-security-scanning-issues.yml new file mode 100644 index 000000000..84f128b0a --- /dev/null +++ b/.github/workflows/create-gh-security-scanning-issues.yml @@ -0,0 +1,67 @@ +name: Create GitHub Security Scanning Issues + +on: + workflow_call: + inputs: + artifact-name: + description: 'Name of the artifact containing code scanning alerts' + required: false + type: string + default: gh-security-scanning-alerts + +permissions: + issues: write + security-events: read + +jobs: + create-gh-security-scanning-issues: + name: Create GitHub Security Scanning Issues + runs-on: ubuntu-latest + permissions: + issues: write + security-events: read + env: + GH_TOKEN: ${{ github.token }} + OWNER: ${{ github.repository_owner }} + REPO: ${{ github.event.repository.name }} + steps: + - name: Download alerts artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.artifact-name }} + + - name: Create backlog issues for new findings + shell: bash + run: | + while IFS= read -r alert; do + RULE_ID=$(echo "$alert" | jq -r '.RuleId') + RULE_DESC=$(echo "$alert" | jq -r '.RuleDescription') + SEVERITY=$(echo "$alert" | jq -r '.SecuritySeverity // "unspecified"') + TOOL=$(echo "$alert" | jq -r '.Tool') + COUNT=$(echo "$alert" | jq -r '.Count') + PATHS=$(echo "$alert" | jq -r '.SamplePaths | join(", ")') + MARKER="automation:security-scan:${RULE_ID}" + + existing=$(gh issue list \ + --repo "${OWNER}/${REPO}" \ + --search "\"[Security] ${RULE_DESC}\" in:title" \ + --state open --json number --jq '.[0].number // empty') + + if [[ -z "$existing" ]]; then + gh issue create \ + --repo "${OWNER}/${REPO}" \ + --title "[Security] ${RULE_DESC}" \ + --label "security" \ + --body " + ## Code Scanning Alert: ${RULE_DESC} + + **Rule:** \`${RULE_ID}\` + **Severity:** ${SEVERITY} + **Tool:** ${TOOL} + **Affected files:** ${COUNT} occurrences + + ### Sample affected paths + ${PATHS} + " + fi + done < <(jq -c '.[]' alerts.json) diff --git a/.github/workflows/gh-code-scanning.yml b/.github/workflows/gh-code-scanning.yml new file mode 100644 index 000000000..a69c00012 --- /dev/null +++ b/.github/workflows/gh-code-scanning.yml @@ -0,0 +1,42 @@ +name: GitHub Code Scanning + +on: + workflow_call: + +permissions: + contents: read + security-events: read + +jobs: + scan: + name: Fetch Code Scanning Alerts + runs-on: ubuntu-latest + permissions: + contents: read + security-events: read + env: + GH_TOKEN: ${{ github.token }} + OWNER: ${{ github.repository_owner }} + REPO: ${{ github.event.repository.name }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Fetch code scanning alerts + shell: pwsh + run: | + $alerts = & .github/skills/github/gh-code-scanning/scripts/Get-CodeScanningAlerts.ps1 ` + -Owner $env:OWNER ` + -Repo $env:REPO ` + -OutputFormat Json + $alerts | Set-Content alerts.json -Encoding utf8 + + - name: Upload alerts artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gh-code-scanning-alerts + path: alerts.json + retention-days: 30 diff --git a/.github/workflows/weekly-gh-code-scanning.yml b/.github/workflows/weekly-gh-code-scanning.yml new file mode 100644 index 000000000..79c1d158d --- /dev/null +++ b/.github/workflows/weekly-gh-code-scanning.yml @@ -0,0 +1,31 @@ +name: Weekly GitHub Code Scanning + +on: + schedule: + - cron: '0 3 * * 1' # Mondays at 03:00 UTC + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + issues: write + security-events: read + +jobs: + gh-code-scanning: + name: GitHub Code Scanning + uses: ./.github/workflows/gh-code-scanning.yml + permissions: + contents: read + security-events: read + + create-gh-code-scanning-issues: + name: Create GitHub Code Scanning Issues + needs: [gh-code-scanning] + uses: ./.github/workflows/create-gh-code-scanning-issues.yml + permissions: + issues: write + security-events: read diff --git a/scripts/security/Get-CodeScanningAlerts.ps1 b/scripts/security/Get-CodeScanningAlerts.ps1 deleted file mode 120000 index 7f294ef5e..000000000 --- a/scripts/security/Get-CodeScanningAlerts.ps1 +++ /dev/null @@ -1 +0,0 @@ -../../.github/skills/github/gh-code-scanning/scripts/Get-CodeScanningAlerts.ps1 \ No newline at end of file