Skip to content
Open
Show file tree
Hide file tree
Changes from 14 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
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 $_ -notmatch '(?i)no file' } |
Comment thread
rezatnoMsirhC marked this conversation as resolved.
Outdated
Comment thread
rezatnoMsirhC marked this conversation as resolved.
Outdated
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 @@ -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,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'
}
}

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

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
Comment thread
rezatnoMsirhC marked this conversation as resolved.
Outdated
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="<!-- ${MARKER} -->
Comment thread
rezatnoMsirhC marked this conversation as resolved.
Outdated
## 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.")
Comment thread
rezatnoMsirhC marked this conversation as resolved.
Outdated

${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}" \
Comment thread
rezatnoMsirhC marked this conversation as resolved.
Outdated
--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)
67 changes: 67 additions & 0 deletions .github/workflows/create-gh-security-scanning-issues.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: Create GitHub Security Scanning Issues
Comment thread
rezatnoMsirhC marked this conversation as resolved.
Outdated

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(", ")')
Comment thread
rezatnoMsirhC marked this conversation as resolved.
Outdated
MARKER="automation:security-scan:${RULE_ID}"

Comment thread
rezatnoMsirhC marked this conversation as resolved.
Outdated
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 "<!-- ${MARKER} -->
## 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)
Loading
Loading