Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7c4baf8
feat(workflows): add weekly-security-scanning workflow
rezatnoMsirhC Apr 30, 2026
1206f8f
refactor(workflows): extract gh-security-scanning reusable workflow
rezatnoMsirhC Apr 30, 2026
5c764c2
feat(workflows): extract security scanning issue creation into reusab…
rezatnoMsirhC Apr 30, 2026
9882e45
refactor(workflows): replace owner/repo inputs with github context vars
rezatnoMsirhC Apr 30, 2026
96f79d8
chore(scripts): remove unused symlink for Get-CodeScanningAlerts.ps1
rezatnoMsirhC May 1, 2026
2dc5520
feat(workflows): rename gh-security-scanning to gh-code-scanning and …
rezatnoMsirhC May 1, 2026
24c5303
test(workflows): add temporary pull_request trigger for live validation
rezatnoMsirhC May 1, 2026
8e1cce4
fix(workflows): improve code scanning issue quality
rezatnoMsirhC May 1, 2026
01413f1
docs(skills): update gh-code-scanning SKILL.md output shape
rezatnoMsirhC May 1, 2026
bc030f7
Removing ms.date from gh-code-scanning skill
rezatnoMsirhC May 1, 2026
30d556c
fix(workflows): fix gh issue edit missing title, labels, and duplicat…
rezatnoMsirhC May 1, 2026
9d1e51e
fix(workflows): omit severity bracket when unknown, add Severity fall…
rezatnoMsirhC May 1, 2026
cf56d7e
test(skills): update gh-code-scanning tests for AffectedPaths rename
rezatnoMsirhC May 1, 2026
902d65f
revert(workflows): remove temporary pull_request trigger from weekly …
rezatnoMsirhC May 1, 2026
ab37e93
Merge branch 'main' into feat/1329-gh-security-weekly-scanning-workflow
rezatnoMsirhC May 4, 2026
20b33ea
fix(workflows): address pr-1495 review comments
rezatnoMsirhC May 11, 2026
1bd8039
Merge branch 'main' of https://github.com/microsoft/hve-core into fea…
rezatnoMsirhC May 11, 2026
8292306
Merge branch 'main' of https://github.com/microsoft/hve-core into fea…
rezatnoMsirhC May 12, 2026
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
3 changes: 3 additions & 0 deletions .github/labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@
- name: automation
color: "ededed"
description: "CI/CD and automation improvements"
- name: automated
color: "ededed"
description: "Issues and PRs created or updated by automation"
- name: build
color: "ededed"
description: "Build system and compilation"
Expand Down
52 changes: 32 additions & 20 deletions .github/skills/github/gh-code-scanning/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 $_ -ne 'no file associated with this alert' } |
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ BeforeAll {
$script:OriginalGhPager = $env:GH_PAGER

# Sample alert JSON representing two rules with multiple occurrences
$script:MockAlertJson = '[{"number":1,"rule":{"id":"js/sql-injection","description":"Database query built from user-controlled sources","security_severity_level":"high"},"tool":{"name":"CodeQL"},"most_recent_instance":{"location":{"path":"src/db.js"}}},{"number":2,"rule":{"id":"js/sql-injection","description":"Database query built from user-controlled sources","security_severity_level":"high"},"tool":{"name":"CodeQL"},"most_recent_instance":{"location":{"path":"src/api.js"}}},{"number":3,"rule":{"id":"js/xss","description":"Cross-site scripting vulnerability","security_severity_level":"medium"},"tool":{"name":"CodeQL"},"most_recent_instance":{"location":{"path":"src/render.js"}}}]'
$script:MockAlertJson = '[{"number":1,"rule":{"id":"js/sql-injection","description":"Database query built from user-controlled sources","security_severity_level":"high","severity":"error"},"tool":{"name":"CodeQL"},"html_url":"https://github.com/owner/repo/security/code-scanning/1","most_recent_instance":{"location":{"path":"src/db.js"},"message":{"text":"SQL injection from user input"}}},{"number":2,"rule":{"id":"js/sql-injection","description":"Database query built from user-controlled sources","security_severity_level":"high","severity":"error"},"tool":{"name":"CodeQL"},"html_url":"https://github.com/owner/repo/security/code-scanning/2","most_recent_instance":{"location":{"path":"src/api.js"},"message":{"text":"SQL injection from user input"}}},{"number":3,"rule":{"id":"js/xss","description":"Cross-site scripting vulnerability","security_severity_level":"medium","severity":"warning"},"tool":{"name":"CodeQL"},"html_url":"https://github.com/owner/repo/security/code-scanning/3","most_recent_instance":{"location":{"path":"src/render.js"},"message":{"text":"Unsanitized input rendered"}}}]'
}

AfterAll {
Expand Down Expand Up @@ -84,31 +84,31 @@ 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' {
Comment thread
rezatnoMsirhC marked this conversation as resolved.
# 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
return $noPathJson
}.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
Expand All @@ -118,9 +118,30 @@ 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'
}

It 'Includes Severity field in grouped output' {
$result = & $script:ScriptPath -Owner 'testorg' -Repo 'testrepo' -OutputFormat Json
$parsed = $result | ConvertFrom-Json

$parsed[0].Severity | Should -Be 'error'
}

It 'Includes AlertUrl field in grouped output' {
$result = & $script:ScriptPath -Owner 'testorg' -Repo 'testrepo' -OutputFormat Json
$parsed = $result | ConvertFrom-Json

$parsed[0].AlertUrl | Should -Match '/security/code-scanning/'
}

It 'Includes FindingDescription field in grouped output' {
$result = & $script:ScriptPath -Owner 'testorg' -Repo 'testrepo' -OutputFormat Json
$parsed = $result | ConvertFrom-Json

$parsed[0].FindingDescription | Should -Not -BeNullOrEmpty
}
}

Expand Down
114 changes: 114 additions & 0 deletions .github/workflows/create-gh-code-scanning-issues.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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:
Comment thread
rezatnoMsirhC marked this conversation as resolved.
issues: write
Comment thread
rezatnoMsirhC marked this conversation as resolved.
actions: read

jobs:
create-gh-code-scanning-issues:
name: Create GitHub Code Scanning Issues
runs-on: ubuntu-latest
permissions:
issues: write
actions: 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: |
set -euo pipefail
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=$(cat <<EOF
<!-- ${MARKER} -->
## 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
EOF
)

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}" \
--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)
Loading
Loading