This folder contains the scripts and conventions to turn GitHub Code Scanning alerts (SARIF-based, e.g. AquaSec) into a managed GitHub Issues backlog.
In one sentence: SARIF uploads create alerts; these scripts sync alerts into Issues; labels + structured comments drive lifecycle; reporting is derived from Issues.
- What this is (and what it isn’t)
- Contents
- Quick start (local)
- Run in GitHub Actions (minimal example)
- Shared workflows
- Labels (contract)
- Issue metadata (secmeta)
- Issue structure
- How you “say duplicate / grouped / dismissed / reopened”
- Known data manipulations
- Design: fingerprints and matching
- Current implementation status
- Troubleshooting
- References
- This is an organizational toolkit: copy the scripts (or vendor them) into an application repository and wire them into Actions.
- SARIF is write-only input. Automation reads from GitHub’s Code Scanning alerts API and from GitHub Issues.
- Issues are the system of record for operational work (ownership, postponement, closure reasons, reporting).
| Script | Purpose | Requires |
|---|---|---|
sync_security_alerts.sh |
Main entrypoint: check labels, collect alerts, promote to Issues (local or Actions) | gh, jq, python3 |
check_labels.sh |
Verify that all labels required by the automation exist in the repository | gh |
collect_alert.sh |
Fetch and normalize code scanning alerts into alerts.json |
gh, jq |
promote_alerts.py |
Create/update parent+child Issues from alerts.json and link children under parents |
gh |
send_to_teams.py |
Send a Markdown message to a Microsoft Teams channel via Incoming Webhook | requests |
extract_team_security_stats.py |
Snapshot security Issues for a team across repos | PyGithub, GITHUB_TOKEN |
derive_team_security_metrics.py |
Compute metrics/deltas from snapshots | stdlib |
Prereqs:
- Install and authenticate GitHub CLI:
gh auth login - Install
jq - Python 3.14+ recommended
This is the normal entrypoint for day-to-day use. It runs check_labels.sh, collect_alert.sh, and then promote_alerts.py.
- Collect + promote in one command:
./sync_security_alerts.sh --repo <owner/repo>To do a safe preview (no issue writes):
./sync_security_alerts.sh --repo <owner/repo> --dry-runTo see full body previews in dry-run, use --verbose (or set RUNNER_DEBUG=1).
You can run the individual steps when you need finer control or want to debug the pipeline:
- Collect open alerts:
./collect_alert.sh --repo <owner/repo> --state open --out alerts.json- Promote alerts to Issues:
python3 promote_alerts.py --file alerts.jsonThis is the simplest “after SARIF upload, sync issues” job.
The expected entrypoint is sync_security_alerts.sh (the individual scripts are still available when you need finer control).
name: Promote code scanning alerts to issues
on:
workflow_run:
workflows: ["Upload SARIF"]
types: [completed]
permissions:
security-events: read
issues: write
contents: read
jobs:
promote:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.14'
- name: Collect + promote
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
./github/security/sync_security_alerts.sh --state open --out alerts.jsonThis repository provides reusable GitHub Actions workflows in .github/workflows/.
Application repositories call them with a short caller workflow instead of duplicating the logic.
The workflows/ directory contains ready-to-copy example caller workflows that you drop into your application repository's .github/workflows/ directory.
| Workflow | Trigger (caller) | Purpose |
|---|---|---|
aquasec-night-scan.yml |
schedule / workflow_dispatch |
Runs AquaSec scan, uploads SARIF, then syncs alerts to Issues via sync_security_alerts.sh |
remove-adept-to-close-on-issue-close.yml |
issues: [closed] |
Removes the sec:adept-to-close label from security issues when they are closed |
- Pick a workflow from the table above.
- Copy the matching example caller from
worklows/into your application repository at.github/workflows/.
The caller needs the following repository secrets configured:
| Secret | Required | Purpose |
|---|---|---|
AQUA_KEY |
yes | AquaSec API key |
AQUA_SECRET |
yes | AquaSec API secret |
AQUA_GROUP_ID |
yes | AquaSec group identifier |
AQUA_REPOSITORY_ID |
yes | AquaSec repository identifier |
TEAMS_WEBHOOK_URL |
no | Teams Incoming Webhook URL for new/reopened issue alerts |
Example caller (already available in workflows/aquasec-night-scan.yml):
name: Aquasec Night Scan
on:
schedule:
- cron: '23 2 * * *'
workflow_dispatch:
concurrency:
group: aquasec-night-scan-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
actions: read
issues: write
security-events: write
jobs:
scan:
uses: AbsaOSS/organizational-workflows/.github/workflows/aquasec-night-scan.yml@master
secrets:
AQUA_KEY: ${{ secrets.AQUA_KEY }}
AQUA_SECRET: ${{ secrets.AQUA_SECRET }}
AQUA_GROUP_ID: ${{ secrets.AQUA_GROUP_ID }}
AQUA_REPOSITORY_ID: ${{ secrets.AQUA_REPOSITORY_ID }}
TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }}Example caller (already available in workflows/remove-adept-to-close-on-issue-close.yml):
name: Remove sec:adept-to-close on close
on:
issues:
types: [closed]
permissions:
issues: write
jobs:
remove-label:
uses: AbsaOSS/organizational-workflows/.github/workflows/remove-adept-to-close-on-issue-close.yml@masterNote: The calling repository must grant the permissions the reusable workflow needs (listed in each workflow file). For cross-organization calls the reusable workflow repository must be set to "Accessible from repositories in the organization" under Settings → Actions → General.
This repository contains multiple scripts with different “label contracts”:
promote_alerts.pymines existing issues by--issue-label(default:scope:security) and ensures baseline labelsscope:securityandtype:tech-debton child/parent issues it creates/updates.
sec:src/aquasec-sarif
sec:state/postponedsec:state/needs-review
sec:sev/criticalsec:sev/highsec:sev/mediumsec:sev/low
sec:close/fixedsec:close/false-positivesec:close/accepted-risksec:close/not-applicable
sec:postpone/vendorsec:postpone/platformsec:postpone/roadmapsec:postpone/other
Each security Issue contains exactly one hidden HTML-comment secmeta block.
Minimum recommended keys (child issue):
<!--secmeta
schema=1
type=child
fingerprint=<finding_fingerprint>
repo=org/repo
source=code_scanning
tool=AquaSec
severity=high
rule_id=...
first_seen=YYYY-MM-DD
last_seen=YYYY-MM-DD
postponed_until=
gh_alert_numbers=["123"]
occurrence_count=1
-->
Minimum recommended keys (parent issue):
<!--secmeta
schema=1
type=parent
repo=org/repo
source=code_scanning
tool=AquaSec
severity=high
rule_id=...
first_seen=YYYY-MM-DD
last_seen=YYYY-MM-DD
postponed_until=
-->
The secmeta block is automation-owned (humans express intent via labels and [sec-event] comments).
Recommended example (fingerprint-first):
[SEC][FP=ab12cd34] Stored XSS in HTML rendering
Rules:
FP=is a short prefix of the canonicalfinding_fingerprint(8 chars is enough)POSTPONE=exists only if postponed- Severity is expressed via labels, not the title
All lifecycle changes are logged via structured comments.
Example close event:
[sec-event]
action=close
reason=fixed
detail=Escaped user input in renderer
evidence=PR#123
[/sec-event]
Example postpone event:
[sec-event]
action=postpone
postponed_until=2026-03-15
reason=vendor
[/sec-event]
Use Issue comments to express intent, and have automation translate that intent into labels / state changes / (optionally) GitHub alert actions.
Recommended commands (example format):
/sec duplicate-of #123— mark as duplicate and point to canonical Issue/sec group-into #456— group related findings under a parent Issue/sec dismiss reason=false_positive|accepted_risk|wont_fix comment="..."— close with an auditable reason/sec reopen— reopen a previously closed Issue
Implementation note: the command parsing/side-effects depend on how process_sec_events.py (not yet implemented) evolves. The format above is the intended contract.
As a rule, values placed into issue body templates are passed through unchanged from the collected alert payload. The following are intentional exceptions:
The iso_date() helper is applied to datetime fields before rendering. It strips the
time portion from ISO-8601 timestamps, keeping only the date.
| Field | Template | Raw alert value | Rendered value |
|---|---|---|---|
published_date |
parent | 2026-02-25T08:25:18Z |
2026-02-25 |
scan_date |
child | 2026-02-25T19:37:05.912Z |
2026-02-25 |
first_seen |
child | 2025-09-17T12:46:48.271Z |
2025-09-17 |
secmeta.first_seen |
secmeta | 2026-02-25T08:25:18Z |
2026-02-25 |
secmeta.last_seen |
secmeta | 2026-02-25T14:11:06Z |
2026-02-25 |
Implementation: shared/common.py → iso_date().
The normalize_path() helper is applied to the file field before it is used as a
fingerprint input and stored in secmeta. It performs the following transformations:
- Converts backslashes (
\) to forward slashes (/). - Strips any leading
./prefix (e.g../src/foo.py→src/foo.py). - Strips any leading
/(e.g./src/foo.py→src/foo.py). - Collapses consecutive slashes to a single
/.
The normalised path is used only for matching and fingerprint computation; the original path string from the alert is never written back to the issue body.
Implementation: shared/common.py → normalize_path().
The current promote_alerts.py implementation expects the scanner to embed a stable fingerprint in the alert instance message text as a line in the form:
Alert hash: <value>
That Alert hash value is treated as the canonical fingerprint and is used to match Issues.
promote_alerts.py stores:
fingerprint: canonical finding identity (fromAlert hash)gh_alert_numbers: list of GitHub alert numbers observed for this findingoccurrence_countandlast_occurrence_fp: best-effort occurrence tracking over time
For tracking repeat sightings, promote_alerts.py computes an occurrence fingerprint from:
commit_sha + path + start_line + end_line
Even with your own Issue fingerprint, you want GitHub alerts to remain stable:
- normalize SARIF paths to repo-relative
artifactLocation.uri - keep
ruleIdstable - avoid unnecessary result reordering
As of 2026-02, github/security/promote_alerts.py implements the fingerprint-based sync loop described above:
- Matches issues strictly by
secmeta.fingerprint(from the alert messageAlert hash: ...) - Ensures a parent issue per
rule_id(secmeta.type=parent) and links child issues under the parent using GitHub sub-issues - Writes/updates
secmetaon child issues, includinggh_alert_numbers,first_seen,last_seen,last_seen_commit, and occurrence tracking - Reopens a closed matching Issue when an alert is open again
- Adds
[sec-event]comments only for meaningful events (reopen, new occurrence)
gh: command not found: install GitHub CLI and ensure it’s onPATH.gh auth statusfails: rungh auth loginlocally, or setGH_TOKENin Actions.- Permission errors in Actions: ensure the workflow has
security-events: readandissues: writepermissions. Output file alerts.json exists:collect_alert.shrefuses to overwrite output; delete the file or pass a different--outpath.missing 'alert hash' in alert message: the scanner/collector needs to include anAlert hash: ...line in the alert instance message text.