Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 4 additions & 7 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,12 @@ src/
│ └── rendering.py # Generic Markdown template renderer
├── security/ # Security workflow domain
│ ├── main.py # Pipeline orchestrator (check → collect → promote)
│ ├── check_labels.py # Verify required labels exist
│ ├── collect_alert.py # Fetch code-scanning alerts → JSON
│ ├── promote_alerts.py # Create/update Issues from alerts
│ ├── send_to_teams.py # Send Adaptive Card to Teams webhook
│ ├── constants.py # Labels, event types, metadata types
│ ├── main.py # Pipeline orchestrator (validate → check → auth → fetch → parse → sync → notify)
│ ├── config.py # SecurityConfig dataclass
│ ├── constants.py # Labels, event types, metadata types, AquaSec API URLs
│ ├── alerts/ # Alert domain (parsing, models)
│ ├── issues/ # Issue management (sync, builder, secmeta)
│ └── notifications/ # Teams webhook notifications
│ └── services/ # Service classes (authenticator, scan_fetcher, label_checker, issue_syncer, notification_sender)
tests/ # Mirrors src/ structure
```
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/aquasec-night-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ permissions:
contents: read
actions: read
issues: write
security-events: write

jobs:
aquasec-night-scan:
Expand Down
47 changes: 12 additions & 35 deletions .github/workflows/aquasec-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# limitations under the License.
#

# SECURITY reusable workflow – AquaSec Scan + Security Alerts to Issues.
# SECURITY reusable workflow – AquaSec Scan to Issues.

name: AquaSec Scan

Expand All @@ -23,16 +23,15 @@ on:
inputs:
dry-run:
description: >
Simulate the issue management side of the workflow without making any persistent changes.
The AquaSec scan and SARIF upload run normally so alerts are fresh.
Simulate the workflow without making any persistent changes.
Issue creation, updates, labelling, sub-issue linking, and Teams notifications are
skipped; all actions that would have been taken are logged in the workflow output instead.
required: false
type: boolean
default: false

verbose-logging:
description: 'Enable verbose logging for AquaSec scan'
description: 'Enable verbose logging'
required: false
type: boolean
default: false
Expand Down Expand Up @@ -81,38 +80,10 @@ permissions:
contents: read
actions: read
issues: write
security-events: write

jobs:
aquasec-scan:
name: AquaSec Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
fetch-depth: 0

- name: Fetch AquaSec Scan Results
id: aquasec
uses: AbsaOSS/aquasec-scan-results@15ee405515a000288b4ae9cdcb9943ea974f74b7
with:
aqua-key: ${{ secrets.AQUA_KEY }}
aqua-secret: ${{ secrets.AQUA_SECRET }}
group-id: ${{ secrets.AQUA_GROUP_ID }}
repository-id: ${{ secrets.AQUA_REPOSITORY_ID }}
verbose-logging: ${{ inputs.verbose-logging }}

- name: Upload scan results to GitHub Security and quality
uses: github/codeql-action/upload-sarif@b71f5aebfc5cee7388c9d7b75e6e36f47787eb9a
with:
sarif_file: ${{ steps.aquasec.outputs.nightscan-sarif-file }}
category: aquasec

sync-alerts-to-issues:
name: Security Alerts to Issues
needs: aquasec-scan
aquasec-scan-to-issues:
name: AquaSec Scan to Issues
runs-on: ubuntu-latest
steps:
- name: Checkout code
Expand All @@ -137,9 +108,15 @@ jobs:
env:
PYTHONPATH: org-workflows/src
GH_TOKEN: ${{ github.token }}
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 }}
SEVERITY_PRIORITY_MAP: ${{ inputs.severity-priority-map }}
PROJECT_NUMBER: ${{ inputs.project-number }}
PROJECT_ORG: ${{ inputs.project-org }}
run: |
python3 org-workflows/src/security/main.py ${{ inputs.dry-run && '--dry-run' || '' }}
python3 org-workflows/src/security/main.py \
${{ inputs.dry-run && '--dry-run' || '' }} \
${{ inputs.verbose-logging && '--verbose' || '' }}
3 changes: 1 addition & 2 deletions docs/security/aquasec-night-scan-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,10 @@ permissions:
contents: read
actions: read
issues: write
security-events: write

jobs:
scan:
uses: AbsaOSS/organizational-workflows/.github/workflows/aquasec-scan.yml@c1fa5b54ff24fea071415da89abc4f0506344f01
uses: AbsaOSS/organizational-workflows/.github/workflows/aquasec-scan.yml@v1.0.0 # Should be changed to release SHA
Comment thread
tmikula-dev marked this conversation as resolved.
with:
dry-run: ${{ inputs.dry-run || false }}
severity-priority-map: 'Critical=Blocker,High=Urgent,Medium=Normal,Low=Minor'
Expand Down
15 changes: 7 additions & 8 deletions docs/security/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@

## Overview

Security Automation provides **continuous, automated vulnerability management** for your repositories. It takes GitHub Code Scanning alerts as an input and automatically converts them into a structured **GitHub Issues** with full lifecycle management. This gives your team a managed security posture without manual triage effort.
Security Automation provides **continuous, automated vulnerability management** for your repositories. It authenticates directly with the AquaSec API, fetches scan findings, and automatically converts them into structured **GitHub Issues** with full lifecycle management. This gives your team a managed security posture without manual triage effort.

> For setup instructions and technical configuration, see the [Security README](/src/security/README.md).

---

## How It Works

This solution is the second part of a two-step security automation pipeline. The first part is [AquaSec Night Scan](https://github.com/AbsaOSS/aquasec-scan-results/blob/master/docs/night-scan-mode.md). It fetches scan findings from AquaSec, converts them into SARIF format, and uploads them to the GitHub Security tab. This solution picks up from there: it reads those alerts and turns them into a managed GitHub Issues backlog.
This solution authenticates with the AquaSec API, fetches scan findings, and converts them into a managed GitHub Issues backlog.

> **Note:** This solution currently supports the AquaSec scanner only.

```mermaid
flowchart TD
A["📋 Code Scanning Alerts\n(in GitHub Security tab)"]
B["📥 Fetch and Normalise Alerts"]
A["🔑 Authenticate with AquaSec API"]
B["📥 Fetch and Normalise Findings"]
C["📝 Create / Update / Reopen Issues"]
D["🏷️ Label Resolved Findings"]
E["📣 Send Notifications"]
Expand All @@ -31,9 +31,9 @@ flowchart TD
style E fill:#2a7a40,color:#fff,stroke:#1a5a28
```

1. **Fetch Alerts from Security Tab**: The automation reads existing Code Scanning alerts from the repository's Security tab. These alerts have to be previously uploaded by a SARIF-producing scanner (e.g. [AquaSec Night Scan](https://github.com/AbsaOSS/aquasec-scan-results)).
1. **Authenticate with AquaSec**: The pipeline authenticates using HMAC-SHA256 signed credentials to obtain a bearer token from the AquaSec API.

2. **Normalise**: Each alert is parsed and normalised into a common format, extracting severity, rule ID, affected file, and a stable fingerprint for matching.
2. **Fetch and Normalise Findings**: Scan results are fetched directly from the AquaSec Code Security API (paginated), then parsed and normalised into a common format extracting severity, rule ID, affected file, and a stable fingerprint for matching.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

3. **Create / Update / Reopen Issues**: New findings become new GitHub Issues. Existing findings are updated with the latest occurrence data. Previously closed findings that reappear are automatically reopened. Parent issues (epics) group findings by rule and auto-close when all children are resolved.

Expand All @@ -45,7 +45,7 @@ flowchart TD

## Key Benefits

- **Zero manual triage**: New findings uploaded to the Security tab automatically become Issues with severity, context, and links to the affected code.
- **Zero manual triage**: New findings from AquaSec scans automatically become Issues with severity, context, and links to the affected code.
- **Single source of truth**: GitHub Issues is the system of record. No need to check a separate security portal.
- **Lifecycle automation**: Issues are reopened when findings reappear, labeled for closure when resolved, and updated if needed.
- **Notifications**: Option to notify the team of new or reopened security findings in real-time.
Expand All @@ -71,7 +71,6 @@ fingerprint=a1b2c3d4e5f6789012345678abcdef01
repo=my-org/my-service
rule_id=CVE-2024-99999
severity=critical
gh_alert_numbers=["7"]
-->

## General Information
Expand Down
12 changes: 7 additions & 5 deletions src/security/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

## Overview

This solution automates the management of security findings by converting GitHub Code Scanning alerts into a structured GitHub Issues backlog.
This solution automates the management of security findings by authenticating directly with the AquaSec API, fetching scan results, and converting them into a structured GitHub Issues backlog.

Solution supports:

Expand Down Expand Up @@ -73,7 +73,7 @@ jobs:

| Name | Description | Required | Default |
|-------------------------|--------------------------------------------------------------------------------------------------------------------|----------|---------|
| `dry-run` | Simulate issue management without making changes. AquaSec scan and SARIF upload run normally. | No | false |
| `dry-run` | Simulate issue management without making changes. | No | false |
| `verbose-logging` | Enable verbose logging for the AquaSec scan step. | No | false |
| `severity-priority-map` | Comma-separated severity=priority pairs. Only listed severities get a priority. When not set, priority is skipped. | No | '' |
| `project-number` | GitHub ProjectV2 number (org-level) for priority sync. Required together with `severity-priority-map`. | No | 0 |
Expand Down Expand Up @@ -132,13 +132,14 @@ jobs:

## Running Locally

The entry point is `src/security/main.py`. It runs the full pipeline: check labels, collect alerts, and promote to Issues.
The entry point is `src/security/main.py`. It runs the full pipeline: authenticate with AquaSec, fetch findings, sync to Issues, and notify if set.

### Prerequisites

- Python 3.14 (current required runtime)
- Install and authenticate GitHub CLI: `gh auth login`
- Required labels must exist in the target repository: `scope:security`, `type:tech-debt`, `sec:adept-to-close`, `epic`
- AquaSec credentials available as environment variables: `AQUA_KEY`, `AQUA_SECRET`, `AQUA_GROUP_ID`, `AQUA_REPOSITORY_ID`

### Commands

Expand All @@ -153,18 +154,21 @@ pip install -r requirements.txt
**Dry-run** (no changes are made, actions are logged):

```bash
AQUA_KEY=... AQUA_SECRET=... AQUA_GROUP_ID=... AQUA_REPOSITORY_ID=... \
PYTHONPATH=src python3 src/security/main.py --repo <owner/repo> --dry-run
```

**Live run** (creates/updates real GitHub Issues):

```bash
AQUA_KEY=... AQUA_SECRET=... AQUA_GROUP_ID=... AQUA_REPOSITORY_ID=... \
PYTHONPATH=src python3 src/security/main.py --repo <owner/repo>
```

**With verbose logging:**

```bash
AQUA_KEY=... AQUA_SECRET=... AQUA_GROUP_ID=... AQUA_REPOSITORY_ID=... \
PYTHONPATH=src python3 src/security/main.py --repo <owner/repo> --dry-run --verbose
```

Expand All @@ -175,13 +179,11 @@ PYTHONPATH=src python3 src/security/main.py --repo <owner/repo> --dry-run --verb
| `--repo` | Target repository (owner/repo). |
| `--dry-run` | Simulate without writing issues. All intended actions are logged. |
| `--verbose` | Enable verbose logging. |
| `--state` | Alert state filter: `open`, `dismissed`, `fixed`, `all` (default: `open`). |
| `--issue-label` | Label used to discover existing security issues (default: `scope:security`). |
| `--severity-priority-map` | Severity-to-priority mapping (default: `$SEVERITY_PRIORITY_MAP`). |
| `--project-number` | ProjectV2 number for priority sync (default: `$PROJECT_NUMBER`). |
| `--project-org` | Org that owns the ProjectV2 board (default: `$PROJECT_ORG`). |
| `--teams-webhook-url` | Teams webhook URL (default: `$TEAMS_WEBHOOK_URL`). |
| `--skip-label-check` | Skip the label existence verification step. |

---

Expand Down
137 changes: 137 additions & 0 deletions src/security/alerts/aquasec_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#
# Copyright 2026 ABSA Group Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

"""Parse AquaSec Night Scan JSON output into Alert dataclasses."""

import logging
from typing import Any

from security.alerts.models import Alert, AlertDetails, AlertMetadata, LoadedAlerts, RuleDetails
from security.constants import LOGGING_PREFIX, SEVERITY_MAP

logger = logging.getLogger(__name__)


def _map_severity(numeric_severity: int) -> str:
"""Map numeric AquaSec severity to lowercase string."""
return SEVERITY_MAP.get(numeric_severity, "unknown")


def _format_bullet_list(items: list[str] | None) -> str:
"""Format a list of strings as a Markdown bullet list."""
if not items:
return ""
return "\n".join(f"- {item}" for item in items)


def _parse_item(item: dict[str, Any], repo: str) -> Alert:
"""Map a single AquaSec JSON item to an Alert dataclass."""
extra = item.get("extraData") or {}
severity_str = _map_severity(item.get("severity", 0))
references_raw = extra.get("references")
references_list = references_raw if isinstance(references_raw, list) else []

metadata = AlertMetadata(
alert_number=0,
state="open",
created_at=item.get("first_seen", ""),
updated_at=item.get("scan_date", ""),
url="",
alert_url="",
rule_id=item.get("avd_id", ""),
rule_name=item.get("category", ""),
rule_description=item.get("title", ""),
severity=severity_str,
confidence="",
tags=[],
help_uri=references_list[0] if references_list else "",
tool="AquaSec",
tool_version="",
ref="",
commit_sha="",
instance_url=None,
classifications=[],
file=item.get("target_file", ""),
start_line=item.get("target_start_line") or None,
end_line=item.get("target_end_line") or None,
)

alert_details = AlertDetails(
alert_hash=item.get("result_hash", ""),
artifact=item.get("target_file", ""),
type=item.get("category", ""),
vulnerability=item.get("avd_id", ""),
severity=severity_str.upper(),
repository=item.get("repository_full_name", ""),
reachable=str(item.get("reachable", False)),
scan_date=item.get("scan_date", ""),
first_seen=item.get("first_seen", ""),
scm_file=item.get("scm_file", ""),
installed_version=item.get("installed_version", ""),
start_line=str(item.get("target_start_line", "") or ""),
end_line=str(item.get("target_end_line", "") or ""),
message=item.get("message", ""),
)

owasp_list = extra.get("owasp")

rule_details = RuleDetails(
type=item.get("category", ""),
severity=severity_str.upper(),
cwe=extra.get("cwe", ""),
fixed_version=item.get("fixed_version", ""),
published_date=item.get("published_date", ""),
package_name=item.get("package_name", ""),
category=extra.get("category", ""),
impact=extra.get("impact", ""),
confidence=extra.get("confidence", ""),
likelihood=extra.get("likelihood", ""),
remediation=extra.get("remediation", ""),
owasp=_format_bullet_list(owasp_list) if isinstance(owasp_list, list) else str(owasp_list or ""),
references=(
_format_bullet_list(references_list) if isinstance(references_list, list) else str(references_list or "")
),
)

return Alert(metadata=metadata, alert_details=alert_details, rule_details=rule_details, repo=repo)


class AquaSecParser:
"""Parses AquaSec scan results into typed Alert objects."""

def __init__(self, repo: str) -> None:
self.repo = repo

def parse(self, data: dict[str, Any]) -> LoadedAlerts:
"""Parse AquaSec scan response dict and return LoadedAlerts.

Args:
data: AquaSec API response dict with 'total' and 'data' keys.

Returns:
LoadedAlerts with all parsed alerts indexed by position.
"""
items = data.get("data", [])
logger.info("%sLoaded %d findings from AquaSec response", LOGGING_PREFIX, len(items))

open_by_number: dict[int, Alert] = {}
for idx, item in enumerate(items, start=1):
alert = _parse_item(item, self.repo)
alert.metadata.alert_number = idx
open_by_number[idx] = alert

logger.info("%sParsed %d alerts for repo %s", LOGGING_PREFIX, len(open_by_number), self.repo)
return LoadedAlerts(repo_full=self.repo, open_by_number=open_by_number)
Loading