From 227087dc154000ccaf6ac5cc8e4bde94843816ef Mon Sep 17 00:00:00 2001 From: "Tobias.Mikula" Date: Thu, 28 May 2026 10:34:53 +0200 Subject: [PATCH 1/5] Migrate AquaSec fetch directly into pipeline, removing SARIF intermediary --- .github/copilot-instructions.md | 11 +- .github/workflows/aquasec-night-scan.yml | 1 - .github/workflows/aquasec-scan.yml | 47 +- docs/security/aquasec-night-scan-example.yml | 3 +- docs/security/security.md | 14 +- src/security/README.md | 12 +- src/security/alerts/aquasec_parser.py | 136 +++++ src/security/alerts/models.py | 6 +- src/security/alerts/parser.py | 147 ----- src/security/check_labels.py | 86 --- src/security/collect_alert.py | 274 ---------- src/security/config.py | 100 ++++ src/security/constants.py | 23 +- src/security/issues/secmeta.py | 25 - src/security/issues/sync.py | 19 +- src/security/main.py | 176 +++--- src/security/notifications/teams.py | 165 ------ src/security/promote_alerts.py | 165 ------ src/security/send_notifications.py | 247 --------- src/security/services/__init__.py | 17 + src/security/services/authenticator.py | 97 ++++ src/security/services/issue_syncer.py | 67 +++ src/security/services/label_checker.py | 58 ++ src/security/services/notification_sender.py | 190 +++++++ src/security/services/scan_fetcher.py | 90 ++++ tests/security/alerts/test_aquasec_parser.py | 224 ++++++++ tests/security/alerts/test_parser.py | 267 --------- tests/security/conftest.py | 2 +- tests/security/issues/test_secmeta.py | 40 -- tests/security/issues/test_sync.py | 25 +- tests/security/notifications/test_teams.py | 216 -------- .../security/services}/__init__.py | 2 + tests/security/services/test_authenticator.py | 91 ++++ tests/security/services/test_issue_syncer.py | 87 +++ .../test_label_checker.py} | 66 +-- .../services/test_notification_sender.py | 201 +++++++ tests/security/services/test_scan_fetcher.py | 124 +++++ tests/security/test_collect_alert.py | 507 ------------------ tests/security/test_config.py | 156 ++++++ tests/security/test_main.py | 327 ++++------- tests/security/test_promote_alerts.py | 251 --------- tests/security/test_send_notifications.py | 219 -------- 42 files changed, 1897 insertions(+), 3084 deletions(-) create mode 100644 src/security/alerts/aquasec_parser.py delete mode 100644 src/security/alerts/parser.py delete mode 100644 src/security/check_labels.py delete mode 100644 src/security/collect_alert.py create mode 100644 src/security/config.py delete mode 100644 src/security/notifications/teams.py delete mode 100644 src/security/promote_alerts.py delete mode 100644 src/security/send_notifications.py create mode 100644 src/security/services/__init__.py create mode 100644 src/security/services/authenticator.py create mode 100644 src/security/services/issue_syncer.py create mode 100644 src/security/services/label_checker.py create mode 100644 src/security/services/notification_sender.py create mode 100644 src/security/services/scan_fetcher.py create mode 100644 tests/security/alerts/test_aquasec_parser.py delete mode 100644 tests/security/alerts/test_parser.py delete mode 100644 tests/security/notifications/test_teams.py rename {src/security/notifications => tests/security/services}/__init__.py (93%) create mode 100644 tests/security/services/test_authenticator.py create mode 100644 tests/security/services/test_issue_syncer.py rename tests/security/{test_check_labels.py => services/test_label_checker.py} (50%) create mode 100644 tests/security/services/test_notification_sender.py create mode 100644 tests/security/services/test_scan_fetcher.py delete mode 100644 tests/security/test_collect_alert.py create mode 100644 tests/security/test_config.py delete mode 100644 tests/security/test_promote_alerts.py delete mode 100644 tests/security/test_send_notifications.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 87226d8..7fe11f9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 ``` diff --git a/.github/workflows/aquasec-night-scan.yml b/.github/workflows/aquasec-night-scan.yml index 90d9187..b2642ba 100644 --- a/.github/workflows/aquasec-night-scan.yml +++ b/.github/workflows/aquasec-night-scan.yml @@ -35,7 +35,6 @@ permissions: contents: read actions: read issues: write - security-events: write jobs: aquasec-night-scan: diff --git a/.github/workflows/aquasec-scan.yml b/.github/workflows/aquasec-scan.yml index 7c070f4..981f57d 100644 --- a/.github/workflows/aquasec-scan.yml +++ b/.github/workflows/aquasec-scan.yml @@ -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 @@ -23,8 +23,7 @@ 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 @@ -32,7 +31,7 @@ on: default: false verbose-logging: - description: 'Enable verbose logging for AquaSec scan' + description: 'Enable verbose logging' required: false type: boolean default: false @@ -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@868e2ea5641bfa2e2af1f144664178b4c5575fab - 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 @@ -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' || '' }} diff --git a/docs/security/aquasec-night-scan-example.yml b/docs/security/aquasec-night-scan-example.yml index e5f6092..4b74984 100644 --- a/docs/security/aquasec-night-scan-example.yml +++ b/docs/security/aquasec-night-scan-example.yml @@ -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 with: dry-run: ${{ inputs.dry-run || false }} severity-priority-map: 'Critical=Blocker,High=Urgent,Medium=Normal,Low=Minor' diff --git a/docs/security/security.md b/docs/security/security.md index 99b80c9..98feb86 100644 --- a/docs/security/security.md +++ b/docs/security/security.md @@ -2,7 +2,7 @@ ## 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). @@ -10,14 +10,14 @@ Security Automation provides **continuous, automated vulnerability management** ## 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"] @@ -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. 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. @@ -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. diff --git a/src/security/README.md b/src/security/README.md index 246e495..0f7bed8 100644 --- a/src/security/README.md +++ b/src/security/README.md @@ -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: @@ -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 | @@ -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 @@ -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 --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 ``` **With verbose logging:** ```bash +AQUA_KEY=... AQUA_SECRET=... AQUA_GROUP_ID=... AQUA_REPOSITORY_ID=... \ PYTHONPATH=src python3 src/security/main.py --repo --dry-run --verbose ``` @@ -175,13 +179,11 @@ PYTHONPATH=src python3 src/security/main.py --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. | --- diff --git a/src/security/alerts/aquasec_parser.py b/src/security/alerts/aquasec_parser.py new file mode 100644 index 0000000..0841343 --- /dev/null +++ b/src/security/alerts/aquasec_parser.py @@ -0,0 +1,136 @@ +# +# 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_list = extra.get("references") or [] + + 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) diff --git a/src/security/alerts/models.py b/src/security/alerts/models.py index b1a074f..2d5b6e2 100644 --- a/src/security/alerts/models.py +++ b/src/security/alerts/models.py @@ -122,7 +122,7 @@ def __post_init__(self) -> None: @dataclass class Alert: - """A single code-scanning alert with its metadata, details, and rule info.""" + """A single security finding parsed from an AquaSec scan result.""" metadata: AlertMetadata = field(default_factory=AlertMetadata) alert_details: AlertDetails = field(default_factory=AlertDetails) @@ -134,7 +134,7 @@ def __post_init__(self) -> None: @classmethod def from_dict(cls, d: dict[str, Any], *, repo: str = "") -> "Alert": - """Construct an Alert from the nested raw dict produced by collect_alert.py.""" + """Construct an Alert from a nested raw dict.""" md = d.get("metadata") or {} ad = d.get("alert_details") or {} rd = d.get("rule_details") or {} @@ -148,7 +148,7 @@ def from_dict(cls, d: dict[str, Any], *, repo: str = "") -> "Alert": @dataclass class LoadedAlerts: - """Result of loading the alerts JSON produced by collect_alert.py.""" + """Collection of parsed Alerts indexed by position.""" repo_full: str open_by_number: dict[int, Alert] diff --git a/src/security/alerts/parser.py b/src/security/alerts/parser.py deleted file mode 100644 index 7223909..0000000 --- a/src/security/alerts/parser.py +++ /dev/null @@ -1,147 +0,0 @@ -# -# 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. -# - -"""Alert data parsing – extracting structured fields from raw alert dicts -(message parameters, CVE, occurrence fingerprint) and loading the alerts -JSON file produced by ``collect_alert.py``. -""" - -import json -import logging -import os -from enum import StrEnum - -from core.helpers import sha256_hex -from security.alerts.models import Alert, AlertDetails, AlertMetadata, LoadedAlerts, RuleDetails - - -class AlertMessageKey(StrEnum): - """Known keys parsed from the multi-line alert message. - - Each value corresponds to the normalised (lowercased, whitespace-collapsed) - key emitted by the AquaSec scan-results action. - """ - - ARTIFACT = "artifact" - TYPE = "type" - VULNERABILITY = "vulnerability" - SEVERITY = "severity" - MESSAGE = "message" - REPOSITORY = "repository" - REACHABLE = "reachable" - SCAN_DATE = "scan date" - FIRST_SEEN = "first seen" - SCM_FILE = "scm file" - INSTALLED_VERSION = "installed version" - START_LINE = "start line" - END_LINE = "end line" - ALERT_HASH = "alert hash" - - -def parse_alert_message_params(message: str | None) -> dict[str, str]: - """Parse key/value parameters from a multi-line alert message. - - Lines are expected in the form: - : - - Keys are normalized to lowercase (and internal whitespace collapsed). - Unknown keys are still included (in lowercase) for debugging. - """ - - params: dict[str, str] = {} - for raw_line in (message or "").splitlines(): - line = raw_line.strip() - if not line or ":" not in line: - continue - key_raw, value_raw = line.split(":", 1) - key = key_raw.strip() - if not key: - continue - value = value_raw.strip() - key_norm = " ".join(key.lower().split()) - params[key_norm] = value - - return params - - -def compute_occurrence_fp(commit_sha: str, path: str, start_line: int | None, end_line: int | None) -> str: - """Compute a SHA-256 fingerprint for a specific alert occurrence location.""" - return sha256_hex(f"{commit_sha}|{path}|{start_line or ''}|{end_line or ''}") - - -def load_open_alerts_from_file(path: str) -> LoadedAlerts: - """Read alerts JSON and return a LoadedAlerts with typed Alert objects.""" - - if not os.path.exists(path): - raise SystemExit(f"ERROR: alerts file not found: {path}") - - with open(path, "r", encoding="utf-8") as fh: - data = json.load(fh) - - repo_meta = data.get("repo") or {} - - repo_full = repo_meta.get("full_name") or "" - if not repo_full: - raise SystemExit(f"ERROR: repo.full_name not found in {path}") - - alerts = data.get("alerts", []) - logging.debug("Loaded %d security alert/s from %s", len(alerts), path) - - open_alerts = [a for a in alerts if str(a.get("metadata", {}).get("state", "")).lower() == "open"] - - open_by_number: dict[int, Alert] = {} - for raw in open_alerts: - metadata = raw.get("metadata") or {} - alert_number = metadata.get("alert_number") - if alert_number is None: - logging.warning(f"Skipping alert with missing alert_number: {raw}") - continue - - try: - alert_number_int = int(alert_number) - except Exception: - logging.warning(f"Skipping alert with invalid alert_number: {alert_number}") - continue - - md = metadata - ad = raw.get("alert_details") or {} - rd = raw.get("rule_details") or {} - - for section_name, section_dict, dataclass_cls in ( - ("metadata", md, AlertMetadata), - ("alert_details", ad, AlertDetails), - ("rule_details", rd, RuleDetails), - ): - unexpected = set(section_dict) - set(dataclass_cls.__dataclass_fields__) - if unexpected: - logging.warning( - f"alert_number={alert_number_int}: unexpected keys in {section_name}: {sorted(unexpected)}" - ) - - alert_obj = Alert( - metadata=AlertMetadata(**{k: v for k, v in md.items() if k in AlertMetadata.__dataclass_fields__}), - alert_details=AlertDetails(**{k: v for k, v in ad.items() if k in AlertDetails.__dataclass_fields__}), - rule_details=RuleDetails(**{k: v for k, v in rd.items() if k in RuleDetails.__dataclass_fields__}), - repo=repo_full, - ) - open_by_number[alert_number_int] = alert_obj - - if os.getenv("DEBUG_ALERTS") == "1": - logging.debug( - f"Full alert payload for alert_number={alert_number_int}:\n" + json.dumps(raw, indent=2, sort_keys=True) - ) - - return LoadedAlerts(repo_full=repo_full, open_by_number=open_by_number) diff --git a/src/security/check_labels.py b/src/security/check_labels.py deleted file mode 100644 index 9427281..0000000 --- a/src/security/check_labels.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -# -# 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. -# - -"""Check that required labels exist in a GitHub repository using ``gh`` CLI.""" - -import argparse -import json -import logging - -from core.config import setup_logging -from core.github.client import run_gh -from security.constants import ( - LABEL_EPIC, - LABEL_SCOPE_SECURITY, - LABEL_SEC_ADEPT_TO_CLOSE, - LABEL_TYPE_TECH_DEBT, -) - -logger = logging.getLogger(__name__) - -REQUIRED_LABELS: list[str] = [ - LABEL_SCOPE_SECURITY, - LABEL_TYPE_TECH_DEBT, - LABEL_EPIC, - LABEL_SEC_ADEPT_TO_CLOSE, -] - - -def fetch_repo_labels(repo: str) -> list[str]: - """Return all label names in *repo* via ``gh label list``.""" - result = run_gh(["label", "list", "--repo", repo, "--json", "name", "--limit", "500"]) - if result.returncode != 0: - logger.error("gh label list failed for %s:\n%s", repo, result.stderr) - raise SystemExit(1) - labels = json.loads(result.stdout) - return [entry["name"] for entry in labels if entry.get("name")] - - -def check_labels(repo: str, required: list[str] | None = None) -> list[str]: - """Return a list of missing labels. Empty list means all labels exist.""" - if required is None: - required = REQUIRED_LABELS - existing = set(fetch_repo_labels(repo)) - return [label for label in required if label not in existing] - - -def main(argv: list[str] | None = None) -> int: - """Check labels exist in the repository and report any that are missing.""" - parser = argparse.ArgumentParser(description="Check that required labels exist in a GitHub repo.") - parser.add_argument("--repo", required=True, help="GitHub repository (owner/repo)") - args = parser.parse_args(argv) - - setup_logging() - - missing = check_labels(args.repo) - - if not missing: - logger.info("All %d required labels exist in %s", len(REQUIRED_LABELS), args.repo) - return 0 - - logger.error( - "%d required label(s) missing in %s\n Missing: %s\n Required: %s", - len(missing), - args.repo, - ", ".join(missing), - ", ".join(REQUIRED_LABELS), - ) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/security/collect_alert.py b/src/security/collect_alert.py deleted file mode 100644 index 0e1aa7d..0000000 --- a/src/security/collect_alert.py +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env python3 -# -# 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. -# - -"""Collect GitHub code-scanning alerts for a repository and write a normalised JSON file.""" - -import argparse -import json -import logging -import os -import re -import shutil -from datetime import datetime, timezone - -from core.config import parse_runner_debug, setup_logging -from core.github.client import run_gh -from security.constants import LOGGING_PREFIX - -logger = logging.getLogger(__name__) - -VALID_STATES = {"open", "dismissed", "fixed", "all"} -RULE_DETAIL_KEYS = [ - "Type", - "Severity", - "CWE", - "Fixed version", - "Published date", - "Package name", - "Category", - "Impact", - "Confidence", - "Likelihood", - "Remediation", - "OWASP", - "References", -] -MULTILINE_KEYS = {"references", "owasp"} - - -def _snake_case(name: str) -> str: - """Convert a display name to snake_case.""" - return name.strip().lower().replace(" ", "_") - - -def _help_value(rule_help: str | None, name: str) -> str | None: - """Extract a value from ``**Name:** value`` markup in the rule help text.""" - if not rule_help: - return None - m = re.search(rf"\*\*{re.escape(name)}:\*\*\s*([^\n\r]+)", rule_help, re.IGNORECASE) - return m.group(1).strip() if m else None - - -def _help_multiline_value(rule_help: str | None, name: str) -> str | None: - """Extract a multi-line value from ``**Name:**`` markup in the rule help text.""" - if not rule_help: - return None - m = re.search( - rf"\*\*{re.escape(name)}:\*\*[ \t]*([^\n\r]*(?:\n(?!\*\*[A-Za-z][\w\s]*?:\*\*)[^\n\r]*)*)", - rule_help, - re.IGNORECASE, - ) - if not m: - return None - return m.group(1).strip() or None - - -def _parse_rule_details(rule_help: str | None) -> dict[str, str | None]: - """Extract known rule detail fields from ``**Key:** value`` markup in rule help.""" - result: dict[str, str | None] = {} - for key in RULE_DETAIL_KEYS: - snake = _snake_case(key) - if snake in MULTILINE_KEYS: - result[snake] = _help_multiline_value(rule_help, key) - else: - result[snake] = _help_value(rule_help, key) - return result - - -def _parse_alert_details(message_text: str) -> dict[str, str]: - """Parse all ``Key: value`` lines from the alert message text.""" - details: dict[str, str] = {} - for line in message_text.split("\n"): - m = re.match(r"^([A-Za-z][\w\s]*?)\s*:\s*(.*)", line) - if m: - key = _snake_case(m.group(1)) - value = m.group(2).rstrip("\r") - details[key] = value - return details - - -def _gh_api_json(endpoint: str) -> dict | list: - """Call ``gh api`` and return parsed JSON.""" - res = run_gh(["api", "-H", "Accept: application/vnd.github+json", endpoint]) - if res.returncode != 0: - logger.error("gh api %s failed:\n%s", endpoint, res.stderr) - raise SystemExit(1) - return json.loads(res.stdout) - - -def _gh_api_paginate(endpoint: str) -> list[dict]: - """Call ``gh api --paginate`` and return the concatenated list of results.""" - res = run_gh(["api", "-H", "Accept: application/vnd.github+json", "--paginate", endpoint]) - if res.returncode != 0: - logger.error("gh api %s failed:\n%s", endpoint, res.stderr) - raise SystemExit(1) - # --paginate may emit multiple JSON arrays back-to-back; decode all of them. - decoder = json.JSONDecoder() - results: list[dict] = [] - text = res.stdout.lstrip() - while text: - obj, idx = decoder.raw_decode(text) - if isinstance(obj, list): - results.extend(obj) - else: - results.append(obj) - text = text[idx:].lstrip() - return results - - -def _normalise_alert(alert: dict) -> dict: - """Transform a raw GitHub code-scanning alert into metadata, alert_details and rule_details.""" - rule = alert.get("rule") or {} - tool = alert.get("tool") or {} - instance = alert.get("most_recent_instance") or {} - location = instance.get("location") or {} - message_text = (instance.get("message") or {}).get("text", "") - rule_help = rule.get("help", "") - - return { - "metadata": { - "alert_number": alert.get("number"), - "state": alert.get("state"), - "created_at": alert.get("created_at"), - "updated_at": alert.get("updated_at"), - "url": alert.get("url"), - "alert_url": alert.get("html_url"), - "rule_id": rule.get("id"), - "rule_name": rule.get("name"), - "rule_description": rule.get("description"), - "severity": rule.get("security_severity_level"), - "confidence": rule.get("severity"), - "tags": rule.get("tags") or [], - "help_uri": rule.get("help_uri"), - "tool": tool.get("name"), - "tool_version": tool.get("version"), - "ref": instance.get("ref"), - "commit_sha": instance.get("commit_sha"), - "instance_url": instance.get("html_url"), - "classifications": instance.get("classifications") or [], - "file": location.get("path"), - "start_line": location.get("start_line"), - "end_line": location.get("end_line"), - }, - "alert_details": _parse_alert_details(message_text), - "rule_details": _parse_rule_details(rule_help), - } - - -def parse_args(argv: list[str] | None = None) -> argparse.Namespace: - """Parse and return CLI arguments.""" - parser = argparse.ArgumentParser( - description="Collect GitHub code-scanning alerts and write a normalised JSON file.", - ) - parser.add_argument( - "--repo", - required=True, - help="GitHub repository in owner/repo format (e.g. my-org/my-repo)", - ) - parser.add_argument( - "--state", - default="open", - choices=sorted(VALID_STATES), - help="Alert state filter (default: open)", - ) - parser.add_argument( - "--out", - default="alerts.json", - dest="out_file", - help="Output file path (default: alerts.json)", - ) - parser.add_argument("--verbose", action="store_true", default=False) - return parser.parse_args(argv) - - -def main(argv: list[str] | None = None) -> None: - """Entry point: collect code-scanning alerts and write normalised JSON.""" - args = parse_args(argv) - - verbose = bool(args.verbose) or parse_runner_debug() - setup_logging(verbose) - - repo: str = args.repo - state: str = args.state - out_file: str = args.out_file - - # Validate repo format - if "/" not in repo: - logger.error("--repo must be in owner/repo format") - raise SystemExit(1) - - # Ensure gh CLI is available - if not shutil.which("gh"): - logger.error("gh CLI is required") - raise SystemExit(1) - - # Ensure gh is authenticated - auth = run_gh(["auth", "status"]) - if auth.returncode != 0: - logger.error("gh is not authenticated") - raise SystemExit(1) - - # Refuse to overwrite - if os.path.exists(out_file): - logger.error("Output file %s exists. Exiting", out_file) - raise SystemExit(1) - - # Fetch repository metadata - logger.info(LOGGING_PREFIX + "Fetching repository metadata") - repo_data = _gh_api_json(f"/repos/{repo}") - assert isinstance(repo_data, dict) - logger.info(LOGGING_PREFIX + "Successfully fetched repository metadata") - - # Fetch code-scanning alerts filtered by state - logger.info(LOGGING_PREFIX + "Fetching %s security alerts", state) - endpoint = f"/repos/{repo}/code-scanning/alerts?per_page=100" - if state != "all": - endpoint += f"&state={state}" - raw_alerts = _gh_api_paginate(endpoint) - - # Assemble output - owner = repo_data.get("owner") or {} - output = { - "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - "repo": { - "id": repo_data.get("id"), - "name": repo_data.get("name"), - "full_name": repo_data.get("full_name"), - "private": repo_data.get("private"), - "html_url": repo_data.get("html_url"), - "default_branch": repo_data.get("default_branch"), - "owner": { - "login": owner.get("login"), - "id": owner.get("id"), - "html_url": owner.get("html_url"), - }, - }, - "query": {"state": state}, - "alerts": [_normalise_alert(a) for a in raw_alerts], - } - - with open(out_file, "w", encoding="utf-8") as f: - json.dump(output, f, indent=2, ensure_ascii=False) - f.write("\n") - - count = len(output["alerts"]) - logger.info(LOGGING_PREFIX + "Successfully fetched %d %s security alert/s", count, state) - logger.debug(LOGGING_PREFIX + "Saved fetched security alerts to: %s", out_file) - - -if __name__ == "__main__": - main() diff --git a/src/security/config.py b/src/security/config.py new file mode 100644 index 0000000..0c32896 --- /dev/null +++ b/src/security/config.py @@ -0,0 +1,100 @@ +# +# 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. +# + +"""Security pipeline configuration model.""" + +import argparse +import logging +import os +import uuid +from dataclasses import dataclass, field + +from security.constants import LABEL_SCOPE_SECURITY, LOGGING_PREFIX + +logger = logging.getLogger(__name__) + + +@dataclass +class SecurityConfig: + """Central configuration for the Security pipeline. + + Loaded from environment variables and CLI arguments, validated upfront. + """ + + aqua_key: str + aqua_secret: str + aqua_group_id: str + aqua_repository_id: str + repo: str + dry_run: bool = False + verbose: bool = False + issue_label: str = field(default=LABEL_SCOPE_SECURITY) + severity_priority_map: str = "" + project_number: int | None = None + project_org: str = "" + teams_webhook_url: str = "" + + @classmethod + def load(cls, args: argparse.Namespace) -> "SecurityConfig": + """Load config from CLI args and environment variables.""" + project_number_raw = args.project_number or os.environ.get("PROJECT_NUMBER", "") + project_number: int | None = None + + if project_number_raw: + try: + project_number = int(project_number_raw) + except ValueError, TypeError: + project_number = None + + return cls( + aqua_key=os.environ.get("AQUA_KEY", ""), + aqua_secret=os.environ.get("AQUA_SECRET", ""), + aqua_group_id=os.environ.get("AQUA_GROUP_ID", ""), + aqua_repository_id=os.environ.get("AQUA_REPOSITORY_ID", ""), + repo=args.repo or os.environ.get("GITHUB_REPOSITORY", ""), + dry_run=bool(args.dry_run), + verbose=bool(args.verbose), + issue_label=args.issue_label, + severity_priority_map=args.severity_priority_map or os.environ.get("SEVERITY_PRIORITY_MAP", ""), + project_number=project_number, + project_org=args.project_org or os.environ.get("PROJECT_ORG", ""), + teams_webhook_url=args.teams_webhook_url or os.environ.get("TEAMS_WEBHOOK_URL", ""), + ) + + def validate(self) -> None: + """Validate configuration and raise SystemExit on failure.""" + errors: list[str] = [] + + if not self.aqua_key: + errors.append("AQUA_KEY: not provided.") + if not self.aqua_secret: + errors.append("AQUA_SECRET: not provided.") + if not self.aqua_group_id: + errors.append("AQUA_GROUP_ID: not provided.") + if not self.aqua_repository_id: + errors.append("AQUA_REPOSITORY_ID: not provided.") + else: + try: + uuid.UUID(self.aqua_repository_id) + except ValueError: + errors.append("AQUA_REPOSITORY_ID: invalid UUID format.") + if not self.repo or "/" not in self.repo: + errors.append("repo: not specified or invalid. Use --repo owner/repo.") + + if errors: + for err in errors: + logger.error("%sConfig validation failed: %s", LOGGING_PREFIX, err) + raise SystemExit("ERROR: Security config validation failed. See errors above.") diff --git a/src/security/constants.py b/src/security/constants.py index 53ef0b9..3e1b161 100644 --- a/src/security/constants.py +++ b/src/security/constants.py @@ -21,8 +21,15 @@ LABEL_EPIC = "epic" LABEL_SEC_ADEPT_TO_CLOSE = "sec:adept-to-close" +REQUIRED_LABELS: list[str] = [ + LABEL_SCOPE_SECURITY, + LABEL_TYPE_TECH_DEBT, + LABEL_EPIC, + LABEL_SEC_ADEPT_TO_CLOSE, +] + SECMETA_KEYS_PARENT = {"type", "repo", "rule_id", "severity"} -SECMETA_KEYS_CHILD = {"type", "fingerprint", "repo", "rule_id", "severity", "gh_alert_numbers"} +SECMETA_KEYS_CHILD = {"type", "fingerprint", "repo", "rule_id", "severity"} SECMETA_TYPE_PARENT = "parent" SECMETA_TYPE_CHILD = "child" @@ -31,5 +38,15 @@ GITHUB_BASE_URL = "https://github.com" -LOGGING_PREFIX = "Security Alerts to Issues - " -DRY_RUN_PREFIX = "Security Alerts to Issues [DRY-RUN] - " +LOGGING_PREFIX = "Security - " +DRY_RUN_PREFIX = "Security [DRY-RUN] - " + +# AquaSec API +AQUA_AUTH_URL = "https://eu-1.api.cloudsploit.com" +AQUA_SCAN_URL = "https://eu-1.codesec.aquasec.com/api/v1/scans/results" +HTTP_TIMEOUT = 30 +FETCH_PAGE_SIZE = 100 +FETCH_SLEEP_SECONDS = 2 + +# Severity mapping (AquaSec numeric → lowercase string) +SEVERITY_MAP: dict[int, str] = {1: "critical", 2: "high", 3: "medium", 4: "low"} diff --git a/src/security/issues/secmeta.py b/src/security/issues/secmeta.py index f816ecc..f110381 100644 --- a/src/security/issues/secmeta.py +++ b/src/security/issues/secmeta.py @@ -16,11 +16,8 @@ """``secmeta`` metadata blocks – parsing, rendering, and upserting the hidden HTML-comment metadata block stored in issue bodies. - -Also hosts generic key-value and JSON-list helpers used for secmeta field values. """ -import json import re @@ -84,28 +81,6 @@ def render_secmeta(secmeta: dict[str, str]) -> str: "repo", "rule_id", "severity", - "gh_alert_numbers", ] lines = render_kv_lines(secmeta, preferred_order) return "" - - -def parse_json_list(value: str | None) -> list[str]: - """Parse a JSON array string into a list of strings.""" - if not value: - return [] - s = value.strip() - try: - parsed = json.loads(s) - if isinstance(parsed, list): - return [str(x) for x in parsed] - except Exception: - pass - # Comma-separated plain values (no brackets expected from json_list()). - parts = [p.strip().strip('"').strip("'") for p in s.split(",")] - return [p for p in parts if p] - - -def json_list(value: list[str]) -> str: - """Serialise a list of strings as a compact JSON array.""" - return json.dumps([str(x) for x in value]) diff --git a/src/security/issues/sync.py b/src/security/issues/sync.py index 30091ee..cbe2f39 100644 --- a/src/security/issues/sync.py +++ b/src/security/issues/sync.py @@ -70,7 +70,7 @@ SyncResult, SyncStats, ) -from .secmeta import json_list, load_secmeta, parse_json_list, render_secmeta +from .secmeta import load_secmeta, render_secmeta from .templates import PARENT_BODY_TEMPLATE @@ -368,7 +368,6 @@ def _handle_new_child_issue( "repo": ctx.repo, "rule_id": ctx.rule_id, "severity": ctx.severity, - "gh_alert_numbers": json_list([str(ctx.alert_number)]), } human_body = build_child_issue_body(ctx.alert) @@ -526,13 +525,6 @@ def _merge_child_secmeta( ) -> dict[str, str]: """Merge incoming alert data into the child issue's secmeta.""" secmeta = load_secmeta(issue.body) or {} - secmeta.pop("alert_hash", None) - - existing_alerts = parse_json_list(secmeta.get("gh_alert_numbers")) - if not existing_alerts and secmeta.get("related_alert_ids"): - existing_alerts = parse_json_list(secmeta.get("related_alert_ids")) - if str(ctx.alert_number) not in existing_alerts: - existing_alerts.append(str(ctx.alert_number)) secmeta.update( { @@ -541,7 +533,6 @@ def _merge_child_secmeta( "repo": ctx.repo, "rule_id": ctx.rule_id or secmeta.get("rule_id", ""), "severity": ctx.severity, - "gh_alert_numbers": json_list(existing_alerts), } ) secmeta = {k: v for k, v in secmeta.items() if k in SECMETA_KEYS_CHILD} @@ -661,14 +652,6 @@ def ensure_issue( ) -> None: """Process a single alert: create or update its child issue and parent.""" alert_number = alert.metadata.alert_number - - alert_state = alert.metadata.state - if alert_state and alert_state != "open": - # This script is designed to process open alerts only! - # Input is typically produced by collect_alert.py with --state open (default). - logging.debug("Skip alert %d: state=%r (only 'open' processed)", alert_number, alert_state) - return - rule_id = alert.metadata.rule_id path = normalize_path(alert.metadata.file) diff --git a/src/security/main.py b/src/security/main.py index 0786b8f..3be8ee7 100644 --- a/src/security/main.py +++ b/src/security/main.py @@ -15,58 +15,46 @@ # limitations under the License. # -"""Orchestrator that runs the full Security pipeline: GH sec-Issues creation.""" +"""Orchestrator that runs the full Security pipeline. +Pipeline: validate config -> check labels -> authenticate -> fetch -> parse -> sync -> notify. +""" import argparse import logging -import os +import shutil from core.config import parse_runner_debug, setup_logging -from security.check_labels import check_labels -from security.collect_alert import main as collect_alert_main +from security.alerts.aquasec_parser import AquaSecParser from security.constants import LOGGING_PREFIX -from security.promote_alerts import main as promote_alerts_main +from security.config import SecurityConfig +from security.services.authenticator import AquaSecAuthenticator +from security.services.issue_syncer import IssueSyncer +from security.services.label_checker import LabelChecker +from security.services.notification_sender import NotificationSender +from security.services.scan_fetcher import ScanFetcher logger = logging.getLogger(__name__) -VALID_STATES = {"open", "dismissed", "fixed", "all"} - - -def _resolve_repo(cli_repo: str) -> str: - """Return *cli_repo* if given, else fall back to ``GITHUB_REPOSITORY``.""" - repo = cli_repo or os.environ.get("GITHUB_REPOSITORY", "") - if not repo or "/" not in repo: - raise SystemExit( - "ERROR: repo not specified or invalid. " "Use --repo owner/repo or set GITHUB_REPOSITORY=owner/repo." - ) - return repo - def parse_args(argv: list[str] | None = None) -> argparse.Namespace: """Parse CLI arguments.""" p = argparse.ArgumentParser( description=( - "Thin orchestrator that runs:\n" - " 1) check_labels -> verify required labels exist\n" - " 2) collect_alert -> writes alerts.json\n" - " 3) promote_alerts -> creates/updates Issues from alerts.json" + "Security pipeline orchestrator:\n" + " 1) Validate configuration\n" + " 2) Check required labels exist\n" + " 3) Authenticate with AquaSec API\n" + " 4) Fetch repository scan findings\n" + " 5) Parse findings\n" + " 6) Sync findings to GitHub Issues\n" + " 7) Send Teams notifications" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) - p.add_argument("--repo", default="", help="GitHub repository (owner/repo)") - p.add_argument( - "--state", - default="open", - choices=sorted(VALID_STATES), - help="Alert state filter (default: open)", - ) p.add_argument( - "--out", - default="alerts.json", - dest="out_file", - help="Output file for alerts JSON (default: alerts.json)", + "--repo", default="", help="GitHub repository (owner/repo). Falls back to $GITHUB_REPOSITORY env var" ) p.add_argument( "--issue-label", @@ -75,42 +63,28 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: ) p.add_argument( "--severity-priority-map", - default=os.environ.get("SEVERITY_PRIORITY_MAP", ""), + default="", help=( "Comma-separated severity=priority pairs, e.g. " "'Critical=Blocker,High=Urgent,Medium=Normal,Low=Minor,Unknown=Normal'. " - "Only listed severities get a priority; unlisted ones are left empty. " - "When not set at all, priority is skipped for every severity. " - "Default: $SEVERITY_PRIORITY_MAP" + "Falls back to $SEVERITY_PRIORITY_MAP env var" ), ) p.add_argument( "--project-number", - default=os.environ.get("PROJECT_NUMBER", ""), - help=( - "GitHub Projects V2 number (org-level) where a Priority " - "single-select field will be set for each promoted issue. " - "Required together with --severity-priority-map. " - "When omitted, project-level priority is skipped. " - "Default: $PROJECT_NUMBER" - ), + default="", + help="GitHub Projects V2 number (org-level) for priority sync. Falls back to $PROJECT_NUMBER env var", ) p.add_argument( "--project-org", - default=os.environ.get("PROJECT_ORG", ""), - help=( - "GitHub organisation that owns the Projects V2 board. " - "Use when the project lives in a different org than the scanned repo. " - "When omitted, derived from the repo name. " - "Default: $PROJECT_ORG" - ), + default="", + help="GitHub organisation that owns the Projects V2 board. Falls back to $PROJECT_ORG env var", ) p.add_argument( "--teams-webhook-url", - default=os.environ.get("TEAMS_WEBHOOK_URL", ""), - help="Teams Incoming Webhook URL (default: $TEAMS_WEBHOOK_URL)", + default="", + help="Teams Incoming Webhook URL. Falls back to $TEAMS_WEBHOOK_URL env var", ) - p.add_argument("--skip-label-check", action="store_true", help="Skip the label existence check") p.add_argument("--dry-run", action="store_true", help="Do not write issues; only print intended actions") p.add_argument( "--verbose", @@ -121,63 +95,53 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: def main(argv: list[str] | None = None) -> int: - """Run the full sync pipeline and return an exit code.""" + """Run the full Security pipeline.""" args = parse_args(argv) - verbose = bool(args.verbose) or parse_runner_debug() - dry_run = bool(args.dry_run) - setup_logging(verbose) - logger.info(LOGGING_PREFIX + "Logging configuration set up") + setup_logging(bool(args.verbose) or parse_runner_debug()) - repo = _resolve_repo(args.repo) - out_file: str = args.out_file + # Load and validate configuration + config = SecurityConfig.load(args) + config.validate() - if dry_run: - logger.info(LOGGING_PREFIX + "Starting the DRY-RUN process for %s", repo) + repo = config.repo + + if dry_run := config.dry_run: + logger.info("%sStarting the DRY-RUN process for %s", LOGGING_PREFIX, repo) else: - logger.info(LOGGING_PREFIX + "Starting process for %s", repo) - - # Label check - if not args.skip_label_check: - missing = check_labels(repo) - if missing: - logger.error( - "Required labels missing in %s: %s", - repo, - ", ".join(missing), - ) - return 1 - logger.info(LOGGING_PREFIX + "All required labels present") - - # Handle existing output file - if os.path.exists(out_file): - logger.debug("Output file is already present: overwriting") - os.remove(out_file) - - # ── Step 3: collect alerts ─────────────────────────────────────── - collect_argv = ["--repo", repo, "--state", args.state, "--out", out_file] - if verbose: - collect_argv.append("--verbose") - collect_alert_main(collect_argv) - - # ── Step 4: promote alerts ─────────────────────────────────────── - promote_argv = ["--file", out_file, "--issue-label", args.issue_label] - if args.dry_run: - promote_argv.append("--dry-run") - if verbose: - promote_argv.append("--verbose") - if args.teams_webhook_url: - promote_argv.extend(["--teams-webhook-url", args.teams_webhook_url]) - if args.severity_priority_map: - promote_argv.extend(["--severity-priority-map", args.severity_priority_map]) - if args.project_number: - promote_argv.extend(["--project-number", str(args.project_number)]) - if args.project_org: - promote_argv.extend(["--project-org", args.project_org]) - - promote_alerts_main(promote_argv) - - logging.info(LOGGING_PREFIX + "Process finished") + logger.info("%sStarting process for %s", LOGGING_PREFIX, repo) + + # Check gh CLI availability + if shutil.which("gh") is None: + raise SystemExit("ERROR: gh CLI is required. Install and authenticate (gh auth login).") + + # Check required labels + if missing := LabelChecker(repo).check_labels(): + logger.error("Required labels missing in %s: %s", repo, ", ".join(missing)) + return 1 + logger.info("%sAll required labels present", LOGGING_PREFIX) + + # Authenticate with AquaSec + authenticator = AquaSecAuthenticator(config.aqua_key, config.aqua_secret, config.aqua_group_id) + bearer_token = authenticator.authenticate() + + # Fetch scan findings + fetcher = ScanFetcher(bearer_token, config.aqua_repository_id) + scan_data = fetcher.fetch_findings() + + # Parse findings + parser = AquaSecParser(repo) + loaded_alerts = parser.parse(scan_data) + open_alerts = loaded_alerts.open_by_number + + # Sync alerts to GitHub Issues + syncer = IssueSyncer(config) + result = syncer.sync(open_alerts, dry_run=dry_run) + + # Send Teams notifications + NotificationSender(config.teams_webhook_url).notify(result, dry_run=dry_run) + + logger.info("%sProcess finished", LOGGING_PREFIX) return 0 diff --git a/src/security/notifications/teams.py b/src/security/notifications/teams.py deleted file mode 100644 index 910603b..0000000 --- a/src/security/notifications/teams.py +++ /dev/null @@ -1,165 +0,0 @@ -# -# 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. -# - -"""Teams webhook notification – builds the notification payload and invokes -``send_notifications.py`` for new / reopened issues. -""" - -import logging -import os -import subprocess -import sys -import tempfile - -from security.constants import DRY_RUN_PREFIX, LOGGING_PREFIX -from security.issues.models import NotifiedIssue, SeverityChange, severity_direction - - -def build_teams_notification_body(notifications: list[NotifiedIssue]) -> str: - """Build a Markdown body summarising new / reopened issues for Teams.""" - new_issues = [n for n in notifications if n.state == "new"] - reopened_issues = [n for n in notifications if n.state == "reopen"] - - lines: list[str] = [] - lines.append(f"**{len(new_issues)}** new, **{len(reopened_issues)}** reopened\n") - - for n in notifications: - state_tag = "new" if n.state == "new" else "reopen" - link = f"https://github.com/{n.repo}/issues/{n.issue_number}" - issue_ref = f"[Issue #{n.issue_number}]({link})" if n.issue_number else "(pending)" - lines.append(f"- **[{state_tag}]** *{n.severity}* - {n.category} - {issue_ref} ({n.repo})\n") - - return "\n".join(lines) - - -def build_severity_change_body(changes: list[SeverityChange]) -> str: - """Build a Markdown body summarising parent-issue severity changes.""" - lines: list[str] = [] - lines.append(f"**{len(changes)}** parent issue(s) with severity change\n") - - for ch in changes: - direction = severity_direction(ch.old_severity, ch.new_severity) - link = f"https://github.com/{ch.repo}/issues/{ch.issue_number}" - issue_ref = f"[Issue #{ch.issue_number}]({link})" - lines.append( - f"- {issue_ref} \u2013 **{ch.old_severity}** \u2192 **{ch.new_severity}** ({direction}) " - f"\u2013 rule_id=`{ch.rule_id}`\n" - ) - - return "\n".join(lines) - - -def _post_to_teams( - webhook_url: str, - body: str, - *, - title: str, - tmp_prefix: str, - label: str, - dry_run: bool = False, -) -> None: - """Write *body* to a temp file and invoke send_notifications.py.""" - script_dir = os.path.dirname(os.path.abspath(__file__)) - send_script = os.path.join(os.path.dirname(script_dir), "send_notifications.py") - - if not os.path.exists(send_script): - logging.warning(f"send_notifications.py not found at {send_script} – skipping {label.lower()}") - return - - body_file: str | None = None - try: - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - prefix=tmp_prefix, - suffix=".md", - delete=False, - ) as tmp: - tmp.write(body) - body_file = tmp.name - - cmd = [ - sys.executable, - send_script, - "--body-file", - body_file, - "--title", - title, - ] - if dry_run: - cmd.append("--dry-run") - else: - cmd.extend(["--webhook-url", webhook_url]) - - result = subprocess.run(cmd, capture_output=True, text=True, check=False) - if result.returncode != 0: - logging.warning(f"{label} failed: {result.stderr}") - else: - if dry_run: - logging.info(DRY_RUN_PREFIX + "Would send Teams notification: %s", result.stdout) - else: - logging.info(LOGGING_PREFIX + "Notification sent: %s", title) - logging.debug("Label: %s output: %s", label.lower(), result.stdout) - finally: - if body_file: - try: - os.remove(body_file) - except OSError: - pass - - -def notify_teams( - webhook_url: str, - notifications: list[NotifiedIssue], - *, - dry_run: bool = False, -) -> None: - """Send a Teams message about new / reopened issues via send_notifications.py.""" - if not notifications: - logging.info(LOGGING_PREFIX + "No issue activity: skipping Teams notification") - return - - body = build_teams_notification_body(notifications) - _post_to_teams( - webhook_url, - body, - title="Aquasec - New/Reopened Security Issues", - tmp_prefix="teams_notification_", - label="Teams notification", - dry_run=dry_run, - ) - - -def notify_teams_severity_changes( - webhook_url: str, - changes: list[SeverityChange], - *, - dry_run: bool = False, -) -> None: - """Send a Teams message about parent severity changes via send_notifications.py.""" - if not changes: - logging.info(LOGGING_PREFIX + "No severity changes: skipping Teams severity notification") - return - - body = build_severity_change_body(changes) - _post_to_teams( - webhook_url, - body, - title="Aquasec - Parent Severity Changes", - tmp_prefix="teams_severity_change_", - label="Teams severity-change notification", - dry_run=dry_run, - ) diff --git a/src/security/promote_alerts.py b/src/security/promote_alerts.py deleted file mode 100644 index 7b3f4db..0000000 --- a/src/security/promote_alerts.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -# -# 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. -# - -"""Promote collected Code Scanning alerts JSON into GitHub Issues. - -Input: -- JSON produced by `collect_alert.py` (default: alerts.json) - -Design intent: -- One Issue per *finding* (stable identity), not per GitHub alert. -- Match Issues strictly by fingerprint (scanner-provided "Alert hash"). -- Store identifiers + lifecycle metadata in a single `secmeta` block in the Issue body. -- Add structured `[sec-event]` comments only for meaningful lifecycle changes (reopen, new occurrence). - -Requirements: -- `gh` CLI (authenticated; uses GH_TOKEN in CI) - - -Draft / debug (no writes): -- Run in dry-run mode to use alert hash, build secmeta, and show intended actions without creating/editing Issues: - `python3 promote_alerts.py --file alerts.json --verbose --dry-run` - -Implementation: -- Core, cross-solution logic lives in the ``core`` package - (common helpers, GitHub wrappers, priority mapping, template renderer). -- Security-specific orchestration and domain logic lives in ``alerts``, ``issues``, ``notifications``. -- This file remains the CLI entry-point only: argument parsing → wiring → main(). -""" - -import argparse -import logging -import os -import shutil - -from core.config import parse_runner_debug, setup_logging -from core.github.issues import gh_issue_list_by_label -from core.priority import parse_severity_priority_map - -from security.alerts.parser import load_open_alerts_from_file -from security.constants import LABEL_SCOPE_SECURITY, LOGGING_PREFIX -from security.issues.sync import sync_alerts_and_issues -from security.notifications.teams import notify_teams, notify_teams_severity_changes - - -def parse_args(argv: list[str] | None = None) -> argparse.Namespace: - """Parse and return CLI arguments.""" - p = argparse.ArgumentParser(description="Promote alerts JSON to GitHub issues using gh CLI") - p.add_argument( - "--file", - "-f", - default="alerts.json", - help="alerts JSON file produced by collect_alert.py (default: alerts.json)", - ) - p.add_argument( - "--dry-run", - action="store_true", - help="Do not create/edit/comment/label issues; only read and print intended actions", - ) - p.add_argument( - "--verbose", - action="store_true", - help="Enable verbose logs (also enabled when RUNNER_DEBUG=1)", - ) - p.add_argument( - "--issue-label", - default=LABEL_SCOPE_SECURITY, - help=f"Only mine issues with this label (default: {LABEL_SCOPE_SECURITY})", - ) - p.add_argument( - "--severity-priority-map", - default=os.environ.get("SEVERITY_PRIORITY_MAP", ""), - help="Comma-separated severity=priority pairs that define which priority string " - "to assign for each alert severity. Severities: Critical, High, Medium, Low, Unknown. " - "Example: 'Critical=Blocker,High=Urgent,Medium=Normal,Low=Minor,Unknown=Normal'. " - "Only listed severities get a priority; unlisted ones are left empty. " - "When not set at all, priority is skipped for every severity. " - "Priority values must match the option names of the Priority single-select field " - "in the target GitHub Project. " - "Default: $SEVERITY_PRIORITY_MAP env var.", - ) - p.add_argument( - "--project-number", - type=int, - default=int(os.environ.get("PROJECT_NUMBER", "0")) or None, - help="GitHub Projects V2 number (org-level) where a Priority single-select field " - "will be set for each promoted issue. Required together with --severity-priority-map. " - "When omitted, project-level priority is skipped. " - "Default: $PROJECT_NUMBER env var.", - ) - p.add_argument( - "--project-org", - default=os.environ.get("PROJECT_ORG", ""), - help="GitHub organisation that owns the Projects V2 board. " - "Use this when the project lives in a different org than the scanned repo. " - "When omitted, the org is derived from the repo name. " - "Default: $PROJECT_ORG env var.", - ) - p.add_argument( - "--teams-webhook-url", - default=os.environ.get("TEAMS_WEBHOOK_URL"), - help="Teams Incoming Webhook URL for new/reopened issue alerts (default: $TEAMS_WEBHOOK_URL). " - "If not set, Teams notification is skipped.", - ) - return p.parse_args(argv) - - -def main(argv: list[str] | None = None) -> None: - """CLI entry-point: load alerts, sync to issues, notify Teams.""" - if shutil.which("gh") is None: - raise SystemExit("ERROR: gh CLI is required. Install and authenticate (gh auth login).") - args = parse_args(argv) - - dry_run = bool(args.dry_run) - verbose = bool(args.verbose) or parse_runner_debug() - setup_logging(verbose) - - logging.info(LOGGING_PREFIX + "Starting promotion of alerts to GitHub issues") - - loaded_alerts = load_open_alerts_from_file(args.file) - repo_full = loaded_alerts.repo_full - open_alerts = loaded_alerts.open_by_number - - issues = gh_issue_list_by_label(repo_full, str(args.issue_label)) - logging.info(LOGGING_PREFIX + "Loaded %d existing security issues for synchronization", len(issues)) - - # Build severity → priority map from user input; empty by default (priority skipped). - spm = parse_severity_priority_map(str(args.severity_priority_map or "")) - - result = sync_alerts_and_issues( - open_alerts, - issues, - dry_run=dry_run, - severity_priority_map=spm, - project_number=args.project_number, - project_org=str(args.project_org or ""), - ) - - logging.info(LOGGING_PREFIX + "Completed promotion of alerts to GitHub issues") - notifications = result.notifications - severity_changes = result.severity_changes - - webhook_url = args.teams_webhook_url - if not webhook_url: - logging.info(LOGGING_PREFIX + "Teams webhook URL not configured: skipping notifications") - else: - notify_teams(webhook_url, notifications, dry_run=dry_run) - notify_teams_severity_changes(webhook_url, severity_changes, dry_run=dry_run) - - -if __name__ == "__main__": - main() diff --git a/src/security/send_notifications.py b/src/security/send_notifications.py deleted file mode 100644 index 3f94b14..0000000 --- a/src/security/send_notifications.py +++ /dev/null @@ -1,247 +0,0 @@ -#!/usr/bin/env python3 -# -# 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. -# - -""" -Send a message to a Microsoft Teams channel via an Incoming Webhook. - -The script accepts a Markdown body (from a file, CLI argument, or stdin) -and delivers it as an Adaptive Card to the configured Teams webhook URL. - -Markdown support note ---------------------- -The body is sent as-is into an Adaptive Card `TextBlock`. Teams renders only a -limited Markdown subset in `TextBlock`. Typically supported formatting: - -- Bold: **text** -- Italic: *text* -- Links: [label](https://example.com) -- Simple lists (bulleted/numbered) -- Line breaks / paragraphs (newlines) - -Any other Markdown (for example: tables, fenced code blocks, images, HTML, -complex/nested formatting) is still delivered exactly as received, but will -likely display as plain text. - -Environment variables ---------------------- -TEAMS_WEBHOOK_URL (required) The Incoming Webhook URL for the target Teams channel. - -Usage examples --------------- -# Body from a file -python3 send_notifications.py --body-file reports/summary.md --title "Security Report" - -# Body from a CLI argument -python3 send_notifications.py --body "All checks **passed** ✅" - -# Body from stdin (pipe) -cat reports/summary.md | python3 send_notifications.py --title "Daily digest" - -# Dry-run (print the payload without sending) -python3 send_notifications.py --body-file reports/summary.md --dry-run -""" - -import argparse -import json -import logging -import os -import sys -from typing import Any, Dict, List - -import requests - -from core.config import parse_runner_debug, setup_logging - - -def _text_block(text: str, **kwargs: Any) -> Dict[str, Any]: - """Return an Adaptive Card TextBlock element.""" - block: Dict[str, Any] = { - "type": "TextBlock", - "text": text, - "wrap": True, - } - block.update(kwargs) - return block - - -def _build_card_body( - body: str, - title: str | None = None, - subtitle: str | None = None, -) -> List[Dict[str, Any]]: - """Assemble the ``body`` array for an Adaptive Card.""" - elements: List[Dict[str, Any]] = [] - - # Optional header container - if title: - header_items: List[Dict[str, Any]] = [ - _text_block(title, weight="Bolder", size="Large"), - ] - if subtitle: - header_items.append( - _text_block(subtitle, isSubtle=True, spacing="None"), - ) - elements.append( - { - "type": "Container", - "style": "accent", - "bleed": True, - "items": header_items, - } - ) - - # Body container - elements.append( - { - "type": "Container", - "separator": bool(title), - "items": [_text_block(body)], - } - ) - - return elements - - -def build_payload( - body: str, - title: str | None = None, - subtitle: str | None = None, -) -> Dict[str, Any]: - """Build the full webhook JSON payload (Adaptive Card message).""" - return { - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "contentUrl": None, - "content": { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.5", - "body": _build_card_body(body, title, subtitle), - }, - } - ], - } - - -def send_to_teams(webhook_url: str, payload: Dict[str, Any]) -> None: - """POST *payload* to the Teams Incoming Webhook and raise on failure.""" - resp = requests.post( - webhook_url, - json=payload, - headers={"Content-Type": "application/json"}, - timeout=30, - ) - # Teams webhooks return 200 with body "1" on success. - if resp.status_code != 200 or resp.text.strip() not in ("1", ""): - raise SystemExit( - f"Teams webhook request failed.\n" f" Status : {resp.status_code}\n" f" Body : {resp.text}" - ) - logging.info("Message sent to Teams successfully.") - - -def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: - """Parse CLI arguments for the Teams webhook sender.""" - parser = argparse.ArgumentParser( - description=( - "Send a Markdown message to a Microsoft Teams channel via Incoming Webhook. " - "The body is sent as-is into an Adaptive Card TextBlock; only a limited Markdown subset " - "is rendered (bold, italic, links, simple lists, newlines). Other Markdown will be delivered " - "as received and may display as plain text." - ), - ) - - body_group = parser.add_mutually_exclusive_group() - body_group.add_argument( - "--body", - help="Inline Markdown body text.", - ) - body_group.add_argument( - "--body-file", - help="Path to a file whose contents will be used as the message body.", - ) - - parser.add_argument( - "--title", - default=None, - help="Optional bold title displayed in the card header.", - ) - parser.add_argument( - "--subtitle", - default=None, - help="Optional subtle subtitle displayed below the title.", - ) - parser.add_argument( - "--webhook-url", - default=os.environ.get("TEAMS_WEBHOOK_URL"), - help="Teams Incoming Webhook URL (default: $TEAMS_WEBHOOK_URL).", - ) - parser.add_argument( - "--verbose", - action="store_true", - help="Enable verbose (DEBUG) logs (also enabled when RUNNER_DEBUG=1).", - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Print the JSON payload to stdout instead of sending it.", - ) - - return parser.parse_args(argv) - - -def _resolve_body(args: argparse.Namespace) -> str: - """Return the message body from --body, --body-file, or stdin.""" - if args.body: - return args.body - - if args.body_file: - with open(args.body_file, encoding="utf-8") as fh: - return fh.read() - - if not sys.stdin.isatty(): - return sys.stdin.read() - - raise SystemExit("No message body provided. Use --body, --body-file, or pipe content via stdin.") - - -def main(argv: list[str] | None = None) -> None: - """CLI entry-point: build an Adaptive Card payload and send it to Teams.""" - args = _parse_args(argv) - verbose = bool(args.verbose) or parse_runner_debug() - setup_logging(verbose) - - body = _resolve_body(args) - if not body.strip(): - raise SystemExit("Message body is empty - nothing to send.") - - webhook_url = args.webhook_url - if not webhook_url and not args.dry_run: - raise SystemExit("No webhook URL provided. Set TEAMS_WEBHOOK_URL or pass --webhook-url.") - - payload = build_payload(body, title=args.title, subtitle=args.subtitle) - - if args.dry_run: - logging.info(json.dumps(payload, indent=2)) - return - - send_to_teams(webhook_url, payload) - - -if __name__ == "__main__": - main() diff --git a/src/security/services/__init__.py b/src/security/services/__init__.py new file mode 100644 index 0000000..c37ed74 --- /dev/null +++ b/src/security/services/__init__.py @@ -0,0 +1,17 @@ +# +# 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. +# + +"""Security services package – AquaSec API integration.""" diff --git a/src/security/services/authenticator.py b/src/security/services/authenticator.py new file mode 100644 index 0000000..f07650d --- /dev/null +++ b/src/security/services/authenticator.py @@ -0,0 +1,97 @@ +# +# 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. +# + +"""AquaSec API authentication via HMAC-SHA256 signed requests.""" + +import hashlib +import hmac +import json +import logging +import time + +import requests + +from security.constants import AQUA_AUTH_URL, HTTP_TIMEOUT, LOGGING_PREFIX + +logger = logging.getLogger(__name__) + + +class AquaSecAuthenticator: + """Authenticates with the AquaSec API and returns a bearer token.""" + + def __init__(self, api_key: str, api_secret: str, group_id: str) -> None: + self.api_key = api_key + self.api_secret = api_secret + self.group_id = group_id + + def _generate_signature(self, string_to_sign: str) -> str: + """Generate HMAC-SHA256 signature for AquaSec API request. + + Args: + string_to_sign: String to sign with HMAC. + + Returns: + Hexadecimal signature string. + """ + return hmac.HMAC( + self.api_secret.encode("utf-8"), + string_to_sign.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + def authenticate(self) -> str: + """Authenticate with AquaSec API and return bearer token. + + Returns: + Bearer token string. + + Raises: + SystemExit: If authentication fails. + """ + logger.info("%sAPI authentication starting.", LOGGING_PREFIX) + + timestamp = int(time.time()) + method = "POST" + auth_endpoint = f"{AQUA_AUTH_URL}/v2/tokens" + post_body = json.dumps( + {"group_id": int(self.group_id), "allowed_endpoints": ["ANY:*"], "validity": 240}, + separators=(",", ":"), + ) + string_to_sign = f"{timestamp}{method}/v2/tokens{post_body}" + + signature = self._generate_signature(string_to_sign) + + headers = { + "Content-Type": "application/json", + "X-API-Key": self.api_key, + "X-Timestamp": str(timestamp), + "X-Signature": signature, + } + + try: + response = requests.post(auth_endpoint, headers=headers, data=post_body, timeout=HTTP_TIMEOUT) + except requests.RequestException as e: + raise SystemExit(f"ERROR: AquaSec authentication request failed: {e}") from e + + if response.status_code != 200: + raise SystemExit(f"ERROR: AquaSec authentication failed. Status {response.status_code}: {response.text}") + + bearer_token = response.json().get("data", "") + if not bearer_token: + raise SystemExit("ERROR: AquaSec API response missing bearer token data.") + + logger.info("%sAPI authentication successful.", LOGGING_PREFIX) + return bearer_token diff --git a/src/security/services/issue_syncer.py b/src/security/services/issue_syncer.py new file mode 100644 index 0000000..292c860 --- /dev/null +++ b/src/security/services/issue_syncer.py @@ -0,0 +1,67 @@ +# +# 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. +# + +"""Issue synchronization service to sync parsed alerts to GitHub Issues.""" + +import logging + +from core.github.issues import gh_issue_list_by_label +from core.priority import parse_severity_priority_map + +from security.alerts.models import Alert +from security.constants import LOGGING_PREFIX +from security.issues.sync import SyncResult, sync_alerts_and_issues +from security.config import SecurityConfig + +logger = logging.getLogger(__name__) + + +class IssueSyncer: + """Synchronizes parsed alerts to GitHub Issues.""" + + def __init__(self, config: SecurityConfig) -> None: + self.config = config + + def sync(self, open_alerts: dict[int, Alert], *, dry_run: bool) -> SyncResult: + """Sync alerts to GitHub Issues and return the result. + + Args: + open_alerts: Parsed alerts indexed by position. + dry_run: If True, no changes are made. + + Returns: + SyncResult with notifications and severity changes. + """ + config = self.config + repo = config.repo + + issues = gh_issue_list_by_label(repo, config.issue_label) + logger.info("%sLoaded %d existing security issues for synchronization", LOGGING_PREFIX, len(issues)) + + spm = parse_severity_priority_map(config.severity_priority_map) + + result = sync_alerts_and_issues( + open_alerts, + issues, + dry_run=dry_run, + severity_priority_map=spm, + project_number=config.project_number, + project_org=config.project_org, + ) + + logger.info("%sCompleted promotion of alerts to GitHub issues", LOGGING_PREFIX) + + return result diff --git a/src/security/services/label_checker.py b/src/security/services/label_checker.py new file mode 100644 index 0000000..1355bd8 --- /dev/null +++ b/src/security/services/label_checker.py @@ -0,0 +1,58 @@ +# +# 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. +# + +"""Label existence checker for GitHub repositories.""" + +import json +import logging + +from core.github.client import run_gh +from security.constants import REQUIRED_LABELS + +logger = logging.getLogger(__name__) + + +class LabelChecker: + """Checks that required labels exist in a GitHub repository.""" + + def __init__(self, repo: str, required: list[str] | None = None) -> None: + self.repo = repo + self.required = required if required is not None else REQUIRED_LABELS + + def check_labels(self) -> list[str]: + """Return a list of missing labels. Empty list means all labels exist. + + Returns: + List of label names that are missing from the repository. + """ + existing = set(self._fetch_labels()) + return [label for label in self.required if label not in existing] + + def _fetch_labels(self) -> list[str]: + """Return all label names in the repository via ``gh label list``. + + Returns: + List of label names. + + Raises: + SystemExit: If the ``gh`` CLI call fails. + """ + result = run_gh(["label", "list", "--repo", self.repo, "--json", "name", "--limit", "500"]) + if result.returncode != 0: + logger.error("gh label list failed for %s:\n%s", self.repo, result.stderr) + raise SystemExit(1) + labels = json.loads(result.stdout) + return [entry["name"] for entry in labels if entry.get("name")] diff --git a/src/security/services/notification_sender.py b/src/security/services/notification_sender.py new file mode 100644 index 0000000..7c1e986 --- /dev/null +++ b/src/security/services/notification_sender.py @@ -0,0 +1,190 @@ +# +# 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. +# + +"""Microsoft Teams notification sender via Incoming Webhook.""" + +import json +import logging +from typing import Any + +import requests + +from security.constants import DRY_RUN_PREFIX, HTTP_TIMEOUT, LOGGING_PREFIX +from security.issues.models import NotifiedIssue, SeverityChange, severity_direction +from security.issues.sync import SyncResult + +logger = logging.getLogger(__name__) + + +class NotificationSender: + """Sends Adaptive Card messages to Microsoft Teams via Incoming Webhook.""" + + def __init__(self, webhook_url: str) -> None: + self.webhook_url = webhook_url + + def notify(self, result: SyncResult, *, dry_run: bool) -> None: + """Send Teams notifications for issue activity and severity changes. + + Args: + result: Sync result containing notifications and severity changes. + dry_run: If True, log intended notifications without sending. + """ + if not self.webhook_url: + logger.info("%sTeams webhook URL not configured: skipping notifications", LOGGING_PREFIX) + return + + self._notify_issues(result.notifications, dry_run=dry_run) + self._notify_severity_changes(result.severity_changes, dry_run=dry_run) + + def _notify_issues(self, notifications: list[NotifiedIssue], *, dry_run: bool) -> None: + """Send a Teams message about new / reopened issues.""" + if not notifications: + logger.info("%sNo issue activity: skipping Teams notification", LOGGING_PREFIX) + return + + body = self._build_issues_body(notifications) + title = "Aquasec - New/Reopened Security Issues" + + if dry_run: + logger.info("%sWould send Teams notification: %s", DRY_RUN_PREFIX, title) + self.send_dry_run(body, title=title) + else: + self.send(body, title=title) + logger.info("%sNotification sent: %s", LOGGING_PREFIX, title) + + def _notify_severity_changes(self, changes: list[SeverityChange], *, dry_run: bool) -> None: + """Send a Teams message about parent severity changes.""" + if not changes: + logger.info("%sNo severity changes: skipping Teams severity notification", LOGGING_PREFIX) + return + + body = self._build_severity_body(changes) + title = "Aquasec - Parent Severity Changes" + + if dry_run: + logger.info("%sWould send Teams notification: %s", DRY_RUN_PREFIX, title) + self.send_dry_run(body, title=title) + else: + self.send(body, title=title) + logger.info("%sNotification sent: %s", LOGGING_PREFIX, title) + + @staticmethod + def _build_issues_body(notifications: list[NotifiedIssue]) -> str: + """Build a Markdown body summarising new / reopened issues for Teams.""" + new_issues = [n for n in notifications if n.state == "new"] + reopened_issues = [n for n in notifications if n.state == "reopen"] + + lines: list[str] = [f"**{len(new_issues)}** new, **{len(reopened_issues)}** reopened\n"] + + for n in notifications: + state_tag = "new" if n.state == "new" else "reopen" + link = f"https://github.com/{n.repo}/issues/{n.issue_number}" + issue_ref = f"[Issue #{n.issue_number}]({link})" if n.issue_number else "(pending)" + lines.append(f"- **[{state_tag}]** *{n.severity}* - {n.category} - {issue_ref} ({n.repo})\n") + + return "\n".join(lines) + + @staticmethod + def _build_severity_body(changes: list[SeverityChange]) -> str: + """Build a Markdown body summarising parent-issue severity changes.""" + lines: list[str] = [f"**{len(changes)}** parent issue(s) with severity change\n"] + + for ch in changes: + direction = severity_direction(ch.old_severity, ch.new_severity) + link = f"https://github.com/{ch.repo}/issues/{ch.issue_number}" + issue_ref = f"[Issue #{ch.issue_number}]({link})" + lines.append( + f"- {issue_ref} \u2013 **{ch.old_severity}** \u2192 **{ch.new_severity}** ({direction}) " + f"\u2013 rule_id=`{ch.rule_id}`\n" + ) + + return "\n".join(lines) + + @staticmethod + def _build_payload( + body: str, + title: str | None = None, + subtitle: str | None = None, + ) -> dict[str, Any]: + """Build the full webhook JSON payload (Adaptive Card message).""" + elements: list[dict[str, Any]] = [] + + if title: + header_items: list[dict[str, Any]] = [ + {"type": "TextBlock", "text": title, "wrap": True, "weight": "Bolder", "size": "Large"}, + ] + if subtitle: + header_items.append( + {"type": "TextBlock", "text": subtitle, "wrap": True, "isSubtle": True, "spacing": "None"}, + ) + elements.append({"type": "Container", "style": "accent", "bleed": True, "items": header_items}) + + elements.append( + { + "type": "Container", + "separator": bool(title), + "items": [{"type": "TextBlock", "text": body, "wrap": True}], + } + ) + + return { + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": None, + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": elements, + }, + } + ], + } + + def send(self, body: str, *, title: str | None = None, subtitle: str | None = None) -> None: + """Send a notification to Teams. + + Args: + body: Markdown body text for the Adaptive Card. + title: Optional bold title in the card header. + subtitle: Optional subtle subtitle below the title. + + Raises: + SystemExit: If the webhook request fails. + """ + payload = self._build_payload(body, title=title, subtitle=subtitle) + + try: + resp = requests.post( + self.webhook_url, + json=payload, + headers={"Content-Type": "application/json"}, + timeout=HTTP_TIMEOUT, + ) + except requests.RequestException as e: + raise SystemExit(f"ERROR: Teams webhook request failed: {e}") from e + + if resp.status_code != 200 or resp.text.strip() not in ("1", ""): + raise SystemExit(f"ERROR: Teams webhook request failed.\n Status: {resp.status_code}\n Body: {resp.text}") + + logger.info("%sMessage sent to Teams successfully.", LOGGING_PREFIX) + + def send_dry_run(self, body: str, *, title: str | None = None, subtitle: str | None = None) -> None: + """Log the payload that would be sent without actually sending.""" + payload = self._build_payload(body, title=title, subtitle=subtitle) + logger.info("%sWould send Teams notification:\n%s", DRY_RUN_PREFIX, json.dumps(payload, indent=2)) diff --git a/src/security/services/scan_fetcher.py b/src/security/services/scan_fetcher.py new file mode 100644 index 0000000..1673715 --- /dev/null +++ b/src/security/services/scan_fetcher.py @@ -0,0 +1,90 @@ +# +# 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. +# + +"""AquaSec scan results fetcher with pagination support.""" + +import json +import logging +import time +from typing import Any + +import requests + +from security.constants import AQUA_SCAN_URL, FETCH_PAGE_SIZE, FETCH_SLEEP_SECONDS, HTTP_TIMEOUT, LOGGING_PREFIX + +logger = logging.getLogger(__name__) + + +class ScanFetcher: + """Fetches security scan findings from AquaSec API with pagination.""" + + def __init__(self, bearer_token: str, repository_id: str) -> None: + self.bearer_token = bearer_token + self.repository_id = repository_id + + def fetch_findings(self) -> dict[str, Any]: + """Fetch all security findings from AquaSec API with pagination. + + Returns: + Dictionary with 'total' count and 'data' list of findings. + + Raises: + SystemExit: If fetching fails. + """ + logger.info("%sScan findings fetch starting.", LOGGING_PREFIX) + + findings: list[dict[str, Any]] = [] + page_num = 1 + total_expected = 0 + headers = {"Authorization": f"Bearer {self.bearer_token}", "Accept": "application/json"} + + while True: + logger.info("%sFetching page %d...", LOGGING_PREFIX, page_num) + + fetch_endpoint = ( + f"{AQUA_SCAN_URL}?repositoryIds={self.repository_id}&size={FETCH_PAGE_SIZE}&page={page_num}" + ) + + try: + response = requests.get(fetch_endpoint, headers=headers, timeout=HTTP_TIMEOUT) + except requests.RequestException as e: + raise SystemExit(f"ERROR: AquaSec scan fetch request failed: {e}") from e + + if response.status_code != 200: + raise SystemExit(f"ERROR: AquaSec scan fetch failed. Status {response.status_code}: {response.text}") + + try: + page_response = response.json() + except json.JSONDecodeError as e: + raise SystemExit(f"ERROR: Invalid JSON response from AquaSec API: {e}") from e + + if page_num == 1: + total_expected = page_response.get("total", 0) + logger.debug("Expected %d total findings.", total_expected) + + page_data = page_response.get("data", []) + findings.extend(page_data) + + if len(findings) >= total_expected or len(page_data) == 0: + break + + page_num += 1 + time.sleep(FETCH_SLEEP_SECONDS) + + findings_total = len(findings) + logger.info("%sScan findings fetch successful (%d total).", LOGGING_PREFIX, findings_total) + + return {"total": findings_total, "data": findings} diff --git a/tests/security/alerts/test_aquasec_parser.py b/tests/security/alerts/test_aquasec_parser.py new file mode 100644 index 0000000..d91a37d --- /dev/null +++ b/tests/security/alerts/test_aquasec_parser.py @@ -0,0 +1,224 @@ +# +# 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. +# + +"""Unit tests for ``security.alerts.aquasec_parser``.""" + +import pytest + +from security.alerts.aquasec_parser import ( + AquaSecParser, + _format_bullet_list, + _map_severity, + _parse_item, +) + + +_VULN_ITEM = { + "category": "vulnerabilities", + "repository_name": "AUL", + "repository_id": "9da7a2cb-6c6e-438d-8bef-afb5aced96e5", + "scan_date": "2026-04-17T22:52:09.391Z", + "target_file": "shared-http-client/pom.xml", + "scm_file": "https://github.com/absa-group/AUL/blob/abc123/shared-http-client/pom.xml", + "scm_link": "", + "target_start_line": 0, + "target_end_line": 0, + "repository_integration_name": "absa-group", + "is_dev_dependency": False, + "repository_full_name": "absa-group/AUL", + "avd_id": "CVE-2026-33870", + "title": "netty-codec-http: Request smuggling via chunked transfer", + "severity": 2, + "resource": "", + "fixed_version": "4.1.132.Final", + "reachable": False, + "message": "Netty incorrectly parses quoted strings in HTTP/1.1 chunked transfer.", + "published_date": "2026-03-27T20:16:34.000Z", + "installed_version": "4.1.100.Final", + "vendor_scoring": {}, + "package_name": "io.netty:netty-codec-http", + "branch": "master", + "is_archived": False, + "source": "github", + "extraData": { + "Fix": {}, + "cwe": "CWE-444: HTTP Request Smuggling", + "CISA": {}, + "EPSS": {"date": "2026-04-16", "score": 0.0004}, + "owasp": ["A03:2017 - Sensitive Data Exposure"], + "impact": "HIGH", + "category": "security/audit", + "confidence": "MEDIUM", + "likelihood": "LOW", + "references": [ + "https://nvd.nist.gov/vuln/detail/CVE-2026-33870", + "https://github.com/netty/netty/security/advisories/GHSA-pwqr-wmgm-9rr8", + ], + "remediation": "Upgrade to 4.1.132.Final or 4.2.10.Final.", + }, + "result_hash": "4a692eb0032260a83dffe57ff99187c7", + "first_seen": "2026-03-27T22:51:04.976Z", + "fingerprint": "", +} + +_SAST_ITEM = { + "category": "sast", + "repository_name": "AUL", + "repository_id": "9da7a2cb-6c6e-438d-8bef-afb5aced96e5", + "scan_date": "2026-04-17T22:52:09.391Z", + "target_file": "scripts/list_domains.py", + "scm_file": "https://github.com/absa-group/AUL/blob/abc123/scripts/list_domains.py", + "scm_link": "https://github.com/absa-group/AUL/blob/abc123/scripts/list_domains.py#L29-L29", + "target_start_line": 29, + "target_end_line": 29, + "repository_integration_name": "absa-group", + "is_dev_dependency": False, + "repository_full_name": "absa-group/AUL", + "avd_id": "insecure-disable-cert-verification-aquasec-python", + "title": "insecure disable cert verification", + "severity": 3, + "resource": "", + "fixed_version": "", + "reachable": False, + "message": "TLS certificate verification is disabled.", + "published_date": "", + "installed_version": "", + "vendor_scoring": {}, + "package_name": "", + "branch": "master", + "is_archived": False, + "source": "github", + "extraData": { + "Fix": {}, + "cwe": "CWE-295: Improper Certificate Validation", + "CISA": {}, + "EPSS": {}, + "owasp": [ + "A03:2017 - Sensitive Data Exposure", + "A07:2021 - Identification and Authentication Failures", + ], + "impact": "LOW", + "category": "security/audit", + "confidence": "LOW", + "likelihood": "LOW", + "references": [ + "https://owasp.org/www-project-top-ten/2017/A3_2017-Sensitive_Data_Exposure", + "https://cwe.mitre.org/data/definitions/295.html", + ], + "remediation": "Do not disable certificate verification in production code.", + }, + "result_hash": "9837b494fe9d15ff6152d6291c2da860", + "first_seen": "2025-09-17T12:46:48.271Z", + "fingerprint": "67529c72212987bef9797e0c8e343fcb09a89e6455493c93a8419620c2a735cc", +} + + +# _map_severity + +@pytest.mark.parametrize("numeric,expected", [ + (1, "critical"), + (2, "high"), + (3, "medium"), + (4, "low"), + (99, "unknown"), + (0, "unknown"), +]) +def test_map_severity(numeric, expected) -> None: + assert _map_severity(numeric) == expected + + +# _format_bullet_list + +def test_format_bullet_list_with_items() -> None: + assert _format_bullet_list(["a", "b"]) == "- a\n- b" + + +def test_format_bullet_list_empty() -> None: + assert _format_bullet_list([]) == "" + + +def test_format_bullet_list_none() -> None: + assert _format_bullet_list(None) == "" + + +# _parse_item + +def test_parse_vulnerability_item() -> None: + alert = _parse_item(_VULN_ITEM, "target-org/target-repo") + + assert alert.repo == "target-org/target-repo" + assert alert.metadata.rule_id == "CVE-2026-33870" + assert alert.metadata.rule_name == "vulnerabilities" + assert alert.metadata.rule_description == "netty-codec-http: Request smuggling via chunked transfer" + assert alert.metadata.severity == "high" + assert alert.metadata.file == "shared-http-client/pom.xml" + assert alert.metadata.start_line is None # 0 maps to None + assert alert.metadata.tool == "AquaSec" + assert alert.metadata.state == "open" + assert alert.metadata.help_uri == "https://nvd.nist.gov/vuln/detail/CVE-2026-33870" + + assert alert.alert_details.alert_hash == "4a692eb0032260a83dffe57ff99187c7" + assert alert.alert_details.artifact == "shared-http-client/pom.xml" + assert alert.alert_details.type == "vulnerabilities" + assert alert.alert_details.vulnerability == "CVE-2026-33870" + assert alert.alert_details.installed_version == "4.1.100.Final" + assert alert.alert_details.reachable == "False" + assert alert.alert_details.repository == "absa-group/AUL" + + assert alert.rule_details.fixed_version == "4.1.132.Final" + assert alert.rule_details.package_name == "io.netty:netty-codec-http" + assert alert.rule_details.cwe == "CWE-444: HTTP Request Smuggling" + assert alert.rule_details.impact == "HIGH" + assert "- https://nvd.nist.gov/vuln/detail/CVE-2026-33870" in alert.rule_details.references + assert alert.rule_details.remediation == "Upgrade to 4.1.132.Final or 4.2.10.Final." + + +def test_parse_sast_item() -> None: + alert = _parse_item(_SAST_ITEM, "target-org/target-repo") + + assert alert.metadata.rule_id == "insecure-disable-cert-verification-aquasec-python" + assert alert.metadata.severity == "medium" + assert alert.metadata.start_line == 29 + assert alert.metadata.end_line == 29 + + assert alert.alert_details.alert_hash == "9837b494fe9d15ff6152d6291c2da860" + assert "- A03:2017 - Sensitive Data Exposure" in alert.rule_details.owasp + assert "- A07:2021 - Identification and Authentication Failures" in alert.rule_details.owasp + + +def test_parse_item_empty_extra_data() -> None: + item = {**_VULN_ITEM, "extraData": {}} + alert = _parse_item(item, "org/repo") + assert alert.rule_details.cwe == "" + assert alert.rule_details.owasp == "N/A" + assert alert.rule_details.references == "N/A" + assert alert.metadata.help_uri == "" + + +def test_parse_item_reachable_true() -> None: + item = {**_VULN_ITEM, "reachable": True} + alert = _parse_item(item, "org/repo") + assert alert.alert_details.reachable == "True" + + +# AquaSecParser.parse + +def test_parse_in_memory() -> None: + data = {"total": 1, "data": [_VULN_ITEM]} + parser = AquaSecParser("org/repo") + result = parser.parse(data) + assert len(result.open_by_number) == 1 + assert result.open_by_number[1].metadata.rule_id == "CVE-2026-33870" diff --git a/tests/security/alerts/test_parser.py b/tests/security/alerts/test_parser.py deleted file mode 100644 index 9f6675a..0000000 --- a/tests/security/alerts/test_parser.py +++ /dev/null @@ -1,267 +0,0 @@ -# -# 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. -# - -"""Unit tests for ``utils.alert_parser``.""" - -import json -import os -import tempfile - -import pytest - -from security.alerts.parser import ( - AlertMessageKey, - compute_occurrence_fp, - load_open_alerts_from_file, - parse_alert_message_params, -) - - -# ── Raw message strings for parse_alert_message_params tests ────────── - -_RAW_SAST_MESSAGE = ( - "Artifact: scripts/create_catalog_tables_and_refresh_partitions/list_domains.py\n" - "Type: sast\n" - "Vulnerability: req-with-very-false-aquasec-python\n" - "Severity: HIGH\n" - "Message: Detected the use of the requests module with verify=False, " - "which disables server certificate validation.\n" - "Repository: test-org/test-repo\n" - "Reachable: False\n" - "Scan date: 2026-02-24T19:24:35.755Z\n" - "First seen: 2025-09-17T12:46:48.271Z\n" - "SCM file: https://github.com/test-org/test-repo/blob/" - "64c62d98a7db5dbd80ae8b0affd531099cf54280/" - "scripts/create_catalog_tables_and_refresh_partitions/list_domains.py\n" - "Start line: 95\n" - "End line: 95\n" - "Alert hash: 3e9c8c338f318e0d06647c2f79406fd4" -) - -_RAW_VULN_MESSAGE = ( - "Artifact: aul-ui/package.json\n" - "Type: vulnerabilities\n" - "Vulnerability: CVE-2026-25755\n" - "Severity: HIGH\n" - "Message: jsPDF is a library to generate PDFs in JavaScript.\n" - "Repository: test-org/test-repo\n" - "Reachable: True\n" - "Scan date: 2026-02-24T19:24:35.755Z\n" - "First seen: 2026-02-20T18:30:18.304Z\n" - "SCM file: https://github.com/test-org/test-repo/blob/" - "64c62d98a7db5dbd80ae8b0affd531099cf54280/aul-ui/package.json\n" - "Installed version: 3.0.3\n" - "Start line: 54\n" - "End line: 54\n" - "Alert hash: 068f963657211cd416dac1f9b30d606c" -) - -_RAW_PIPELINE_MESSAGE = ( - "Artifact: .github/workflows/aquasec-night-scan-example.yml\n" - "Type: pipelineMisconfigurations\n" - "Vulnerability: AVD-PIPELINE-0008\n" - "Severity: MEDIUM\n" - "Message: Dependency AbsaOSS/aquasec-scan-results master version " - "should be pinned to the commit sha\n" - "Repository: test-org/test-repo\n" - "Reachable: False\n" - "Scan date: 2026-02-24T19:24:35.755Z\n" - "First seen: 2026-02-09T15:51:33.454Z\n" - "SCM file: https://github.com/test-org/test-repo/blob/" - "64c62d98a7db5dbd80ae8b0affd531099cf54280/" - ".github/workflows/aquasec-night-scan-example.yml\n" - "Start line: 21\n" - "Alert hash: bed23a624d7f1f07f56a07c6349bcd8b" -) - - -# ===================================================================== -# parse_alert_message_params -# ===================================================================== - - -def test_sast_message_all_keys() -> None: - """All expected keys are extracted from a SAST raw message.""" - params = parse_alert_message_params(_RAW_SAST_MESSAGE) - - assert params["artifact"] == ( - "scripts/create_catalog_tables_and_refresh_partitions/list_domains.py" - ) - assert params["type"] == "sast" - assert params["vulnerability"] == "req-with-very-false-aquasec-python" - assert params["severity"] == "HIGH" - assert "verify=False" in params["message"] - assert params["repository"] == "test-org/test-repo" - assert params["reachable"] == "False" - assert params["scan date"] == "2026-02-24T19:24:35.755Z" - assert params["first seen"] == "2025-09-17T12:46:48.271Z" - assert params["scm file"].startswith("https://github.com/test-org/test-repo/blob/") - assert params["start line"] == "95" - assert params["end line"] == "95" - assert params["alert hash"] == "3e9c8c338f318e0d06647c2f79406fd4" - -def test_vuln_message_installed_version() -> None: - """Vulnerability alert messages include 'Installed version'.""" - params = parse_alert_message_params(_RAW_VULN_MESSAGE) - - assert params["installed version"] == "3.0.3" - assert params["vulnerability"] == "CVE-2026-25755" - assert params["reachable"] == "True" - assert params["alert hash"] == "068f963657211cd416dac1f9b30d606c" - -def test_pipeline_message_no_installed_version() -> None: - """Pipeline misconfiguration alerts have no 'Installed version' or 'End line'.""" - params = parse_alert_message_params(_RAW_PIPELINE_MESSAGE) - - assert "installed version" not in params - assert "end line" not in params - assert params["type"] == "pipelineMisconfigurations" - assert params["start line"] == "21" - -def test_none_message() -> None: - assert parse_alert_message_params(None) == {} - -def test_empty_message() -> None: - assert parse_alert_message_params("") == {} - -def test_message_without_colon() -> None: - assert parse_alert_message_params("no colon here") == {} - -def test_key_normalisation() -> None: - """Keys are lowercased and internal whitespace is collapsed.""" - params = parse_alert_message_params(" Alert Hash : abc123 ") - assert params["alert hash"] == "abc123" - - -# ===================================================================== -# AlertMessageKey enum completeness -# ===================================================================== - - -_EXPECTED_KEYS = { - "artifact", "type", "vulnerability", "severity", "message", - "repository", "reachable", "scan date", "first seen", - "scm file", "installed version", "start line", "end line", - "alert hash", -} - -def test_all_documented_keys_present() -> None: - enum_values = {member.value for member in AlertMessageKey} - assert enum_values == _EXPECTED_KEYS - -def test_sast_message_keys_subset() -> None: - """Every enum value that appears in a SAST message is parseable.""" - params = parse_alert_message_params(_RAW_SAST_MESSAGE) - for key in AlertMessageKey: - if key.value in params: - assert params[key.value], f"Key '{key.value}' parsed but empty" - - -# ===================================================================== -# compute_occurrence_fp -# ===================================================================== - - -def test_deterministic() -> None: - fp1 = compute_occurrence_fp("abc123", "src/main.py", 10, 20) - fp2 = compute_occurrence_fp("abc123", "src/main.py", 10, 20) - assert fp1 == fp2 - -def test_differs_on_commit() -> None: - fp1 = compute_occurrence_fp("abc123", "src/main.py", 10, 20) - fp2 = compute_occurrence_fp("def456", "src/main.py", 10, 20) - assert fp1 != fp2 - -def test_none_lines() -> None: - """None line numbers should not raise.""" - fp = compute_occurrence_fp("abc123", "src/main.py", None, None) - assert isinstance(fp, str) and len(fp) == 64 - - -# ===================================================================== -# load_open_alerts_from_file -# ===================================================================== - - -def _write_alerts_file(alerts: list[dict], repo_full: str = "org/repo") -> str: - """Write a minimal alerts JSON to a temp file and return the path.""" - data = { - "repo": {"full_name": repo_full}, - "alerts": alerts, - } - fd, path = tempfile.mkstemp(suffix=".json") - with os.fdopen(fd, "w", encoding="utf-8") as fh: - json.dump(data, fh) - return path - -def test_loads_open_alerts() -> None: - path = _write_alerts_file([ - {"metadata": {"alert_number": 1, "state": "open"}, "alert_details": {}, "rule_details": {}}, - {"metadata": {"alert_number": 2, "state": "dismissed"}, "alert_details": {}, "rule_details": {}}, - ]) - try: - result = load_open_alerts_from_file(path) - assert result.repo_full == "org/repo" - assert 1 in result.open_by_number - assert 2 not in result.open_by_number - finally: - os.unlink(path) - -def test_enriches_repo() -> None: - path = _write_alerts_file([ - {"metadata": {"alert_number": 10, "state": "open"}, "alert_details": {"alert_hash": "xyz"}, "rule_details": {}}, - ]) - try: - result = load_open_alerts_from_file(path) - alert = result.open_by_number[10] - assert alert.repo == "org/repo" - finally: - os.unlink(path) - -def test_missing_file_exits() -> None: - with pytest.raises(SystemExit): - load_open_alerts_from_file("/nonexistent/path.json") - -def test_missing_repo_full_name_exits() -> None: - fd, path = tempfile.mkstemp(suffix=".json") - with os.fdopen(fd, "w") as fh: - json.dump({"repo": {}, "alerts": []}, fh) - try: - with pytest.raises(SystemExit): - load_open_alerts_from_file(path) - finally: - os.unlink(path) - -def test_skips_alert_without_number() -> None: - path = _write_alerts_file([ - {"metadata": {"state": "open"}, "alert_details": {}, "rule_details": {}}, - ]) - try: - result = load_open_alerts_from_file(path) - assert len(result.open_by_number) == 0 - finally: - os.unlink(path) - -def test_skips_alert_with_invalid_number() -> None: - """Non-integer alert_number values are skipped with a warning.""" - path = _write_alerts_file([ - {"metadata": {"alert_number": "not-a-number", "state": "open"}, "alert_details": {}, "rule_details": {}}, - ]) - try: - result = load_open_alerts_from_file(path) - assert len(result.open_by_number) == 0 - finally: - os.unlink(path) diff --git a/tests/security/conftest.py b/tests/security/conftest.py index 380d613..4827444 100644 --- a/tests/security/conftest.py +++ b/tests/security/conftest.py @@ -18,7 +18,7 @@ Each fixture returns a *mutable copy* so tests can modify it freely. Alerts use the nested schema (``metadata`` / ``alert_details`` / ``rule_details``) -produced by ``collect_alert.py``. +produced by ``AquaSecParser``. """ import pytest diff --git a/tests/security/issues/test_secmeta.py b/tests/security/issues/test_secmeta.py index b90d2b3..6ea8b37 100644 --- a/tests/security/issues/test_secmeta.py +++ b/tests/security/issues/test_secmeta.py @@ -16,12 +16,8 @@ """Unit tests for ``utils.secmeta``.""" -import pytest - from security.issues.secmeta import ( - json_list, load_secmeta, - parse_json_list, parse_kv_block, render_secmeta, ) @@ -136,39 +132,3 @@ def test_extra_keys_sorted() -> None: alpha_idx = next(i for i, l in enumerate(lines) if "alpha=" in l) zebra_idx = next(i for i, l in enumerate(lines) if "zebra=" in l) assert alpha_idx < zebra_idx - - -# ===================================================================== -# parse_json_list / json_list -# ===================================================================== - - -def test_json_array() -> None: - assert parse_json_list('["a","b","c"]') == ["a", "b", "c"] - -def test_comma_separated_fallback() -> None: - assert parse_json_list("a, b, c") == ["a", "b", "c"] - -def test_parse_json_list_empty() -> None: - assert parse_json_list("") == [] - -def test_parse_json_list_none() -> None: - assert parse_json_list(None) == [] - -def test_single_value() -> None: - assert parse_json_list('["only"]') == ["only"] - -def test_numeric_values() -> None: - assert parse_json_list("[1, 2, 3]") == ["1", "2", "3"] - - -def test_serialize() -> None: - result = json_list(["a", "b"]) - assert result == '["a", "b"]' - -def test_json_list_empty() -> None: - assert json_list([]) == "[]" - -def test_json_list_roundtrip() -> None: - original = ["303", "304", "305"] - assert parse_json_list(json_list(original)) == original diff --git a/tests/security/issues/test_sync.py b/tests/security/issues/test_sync.py index 2698905..02b7aa1 100644 --- a/tests/security/issues/test_sync.py +++ b/tests/security/issues/test_sync.py @@ -312,8 +312,8 @@ def test_append_notification_none() -> None: # ===================================================================== -def test_merge_new_alert_number() -> None: - """New alert number is appended to gh_alert_numbers.""" +def test_merge_removes_gh_alert_numbers() -> None: + """Legacy gh_alert_numbers key is dropped during merge.""" child = _issue_with_secmeta(1, { "type": "child", "fingerprint": "fp1", @@ -321,8 +321,7 @@ def test_merge_new_alert_number() -> None: }) ctx = _make_alert_context(alert_number=200, fingerprint="fp1") secmeta = _merge_child_secmeta(ctx=ctx, issue=child) - assert "200" in secmeta["gh_alert_numbers"] - assert "100" in secmeta["gh_alert_numbers"] + assert "gh_alert_numbers" not in secmeta def test_merge_removes_alert_hash() -> None: @@ -357,7 +356,7 @@ def test_merge_strips_legacy_secmeta_keys() -> None: }) ctx = _make_alert_context(alert_number=200, fingerprint="fp1") secmeta = _merge_child_secmeta(ctx=ctx, issue=child) - expected_keys = {"type", "fingerprint", "repo", "rule_id", "severity", "gh_alert_numbers"} + expected_keys = {"type", "fingerprint", "repo", "rule_id", "severity"} assert expected_keys == set(secmeta.keys()) @@ -1027,18 +1026,6 @@ def test_ensure_issue_dry_run(sast_alert: Alert) -> None: assert notifications[0].issue_number == 0 -def test_ensure_issue_skips_non_open() -> None: - """Alerts with state != 'open' are skipped.""" - alert = Alert.from_dict({ - "metadata": {"alert_number": 1, "state": "dismissed"}, - "alert_details": {}, - "rule_details": {}, - }) - issues: dict[int, Issue] = {} - index = IssueIndex(by_fingerprint={}, parent_by_rule_id={}) - ensure_issue(alert, _make_sync_context(issues=issues, index=index, dry_run=True)) - - def test_ensure_issue_missing_alert_hash_raises() -> None: """Raises SystemExit when alert hash is missing.""" alert = Alert.from_dict({ @@ -1182,8 +1169,8 @@ def test_init_priority_sync_field_lookup_fails(mocker: MockerFixture) -> None: @pytest.mark.parametrize("dry_run,prefix", [ - (False, "Security Alerts to Issues - "), - (True, "Security Alerts to Issues [DRY-RUN] - "), + (False, "Security - "), + (True, "Security [DRY-RUN] - "), ]) def test_log_sync_summary(caplog: pytest.LogCaptureFixture, dry_run: bool, prefix: str) -> None: """Summary emits correct prefix, grouped table, and collapses empty groups.""" diff --git a/tests/security/notifications/test_teams.py b/tests/security/notifications/test_teams.py deleted file mode 100644 index 6a93759..0000000 --- a/tests/security/notifications/test_teams.py +++ /dev/null @@ -1,216 +0,0 @@ -# -# 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. -# - -"""Unit tests for ``utils.teams``.""" - - -import logging -import types - -import pytest - -from security.issues.models import NotifiedIssue, SeverityChange -from security.notifications.teams import ( - build_severity_change_body, - build_teams_notification_body, - notify_teams, - notify_teams_severity_changes, -) - - -# ===================================================================== -# Fixtures -# ===================================================================== - -@pytest.fixture -def sample_notifications() -> list[NotifiedIssue]: - return [ - NotifiedIssue( - repo="org/repo-a", issue_number=10, severity="high", - category="sast", state="new", tool="AquaSec", - ), - NotifiedIssue( - repo="org/repo-b", issue_number=20, severity="medium", - category="sca", state="reopen", tool="AquaSec", - ), - ] - - -@pytest.fixture -def sample_changes() -> list[SeverityChange]: - return [ - SeverityChange( - repo="org/repo-a", issue_number=5, rule_id="CVE-2026-1234", - old_severity="medium", new_severity="critical", - ), - ] - - -# ===================================================================== -# build_teams_notification_body -# ===================================================================== - - -def test_counts_new_and_reopened(sample_notifications: list[NotifiedIssue]) -> None: - body = build_teams_notification_body(sample_notifications) - assert "**1** new" in body - assert "**1** reopened" in body - -def test_contains_issue_links(sample_notifications: list[NotifiedIssue]) -> None: - body = build_teams_notification_body(sample_notifications) - assert "https://github.com/org/repo-a/issues/10" in body - assert "https://github.com/org/repo-b/issues/20" in body - -def test_contains_severity(sample_notifications: list[NotifiedIssue]) -> None: - body = build_teams_notification_body(sample_notifications) - assert "*high*" in body - assert "*medium*" in body - -def test_contains_state_tag(sample_notifications: list[NotifiedIssue]) -> None: - body = build_teams_notification_body(sample_notifications) - assert "[new]" in body - assert "[reopen]" in body - -def test_empty_notifications() -> None: - body = build_teams_notification_body([]) - assert "**0** new" in body - assert "**0** reopened" in body - -def test_pending_issue_number_zero() -> None: - """issue_number=0 produces a '(pending)' fallback instead of an issue link.""" - n = NotifiedIssue( - repo="org/repo-a", issue_number=0, severity="high", - category="sast", state="new", tool="AquaSec", - ) - body = build_teams_notification_body([n]) - assert "(pending)" in body - assert "issues/0" not in body - - -# ===================================================================== -# build_severity_change_body -# ===================================================================== - - -def test_counts_changes(sample_changes: list[SeverityChange]) -> None: - body = build_severity_change_body(sample_changes) - assert "**1** parent issue(s)" in body - -def test_contains_issue_link(sample_changes: list[SeverityChange]) -> None: - body = build_severity_change_body(sample_changes) - assert "https://github.com/org/repo-a/issues/5" in body - -def test_contains_direction(sample_changes: list[SeverityChange]) -> None: - body = build_severity_change_body(sample_changes) - assert "escalated" in body - -def test_contains_severities(sample_changes: list[SeverityChange]) -> None: - body = build_severity_change_body(sample_changes) - assert "**medium**" in body - assert "**critical**" in body - -def test_contains_rule_id(sample_changes: list[SeverityChange]) -> None: - body = build_severity_change_body(sample_changes) - assert "CVE-2026-1234" in body - - -@pytest.fixture -def _mock_subprocess_ok(monkeypatch: pytest.MonkeyPatch): - """Patch subprocess.run to succeed and os.path.exists to return True.""" - calls: list[tuple] = [] - - def fake_run(cmd, **kwargs): - calls.append((cmd, kwargs)) - return types.SimpleNamespace(returncode=0, stdout="ok", stderr="") - - monkeypatch.setattr("security.notifications.teams.subprocess.run", fake_run) - monkeypatch.setattr("security.notifications.teams.os.path.exists", lambda _: True) - return calls - - -# ===================================================================== -# notify_teams – subprocess interactions (mocked) -# ===================================================================== - - -def test_notify_teams_dry_run_calls_subprocess( - sample_notifications: list[NotifiedIssue], - _mock_subprocess_ok: list[tuple], -) -> None: - notify_teams("https://hook", sample_notifications, dry_run=True) - assert len(_mock_subprocess_ok) == 1 - cmd = _mock_subprocess_ok[0][0] - assert "--dry-run" in cmd - -def test_notify_teams_skips_when_script_not_found( - monkeypatch: pytest.MonkeyPatch, - sample_notifications: list[NotifiedIssue], - caplog: pytest.LogCaptureFixture, -) -> None: - calls: list = [] - monkeypatch.setattr("security.notifications.teams.subprocess.run", lambda cmd, **kw: calls.append(cmd)) - monkeypatch.setattr("security.notifications.teams.os.path.exists", lambda _: False) - with caplog.at_level(logging.WARNING): - notify_teams("https://hook", sample_notifications, dry_run=False) - assert len(calls) == 0 - assert any("not found" in r.message.lower() for r in caplog.records) - - -def test_notify_teams_real_sends_webhook( - sample_notifications: list[NotifiedIssue], - _mock_subprocess_ok: list[tuple], -) -> None: - """Non-dry-run path passes --webhook-url to subprocess.""" - notify_teams("https://hook", sample_notifications, dry_run=False) - assert len(_mock_subprocess_ok) == 1 - cmd = _mock_subprocess_ok[0][0] - assert "--webhook-url" in cmd - assert "--dry-run" not in cmd - - -def test_notify_teams_subprocess_failure( - monkeypatch: pytest.MonkeyPatch, - sample_notifications: list[NotifiedIssue], - caplog: pytest.LogCaptureFixture, -) -> None: - """Subprocess failure logs a warning and does not raise.""" - def fake_run(cmd, **kwargs): - return types.SimpleNamespace(returncode=1, stdout="", stderr="send failed") - - monkeypatch.setattr("security.notifications.teams.subprocess.run", fake_run) - monkeypatch.setattr("security.notifications.teams.os.path.exists", lambda _: True) - with caplog.at_level(logging.WARNING): - notify_teams("https://hook", sample_notifications, dry_run=False) - assert any("failed" in r.message.lower() for r in caplog.records) - - -# ===================================================================== -# notify_teams_severity_changes – subprocess interactions (mocked) -# ===================================================================== - - -def test_severity_changes_skips_when_empty() -> None: - """Empty list is silently accepted - no subprocess call.""" - notify_teams_severity_changes("https://hook", [], dry_run=False) - -def test_severity_changes_dry_run( - sample_changes: list[SeverityChange], - _mock_subprocess_ok: list[tuple], -) -> None: - notify_teams_severity_changes("https://hook", sample_changes, dry_run=True) - assert len(_mock_subprocess_ok) == 1 - cmd = _mock_subprocess_ok[0][0] - assert "--dry-run" in cmd diff --git a/src/security/notifications/__init__.py b/tests/security/services/__init__.py similarity index 93% rename from src/security/notifications/__init__.py rename to tests/security/services/__init__.py index ebfbdd3..6d963e6 100644 --- a/src/security/notifications/__init__.py +++ b/tests/security/services/__init__.py @@ -13,3 +13,5 @@ # See the License for the specific language governing permissions and # limitations under the License. # + +"""Tests for security services package.""" diff --git a/tests/security/services/test_authenticator.py b/tests/security/services/test_authenticator.py new file mode 100644 index 0000000..87591f8 --- /dev/null +++ b/tests/security/services/test_authenticator.py @@ -0,0 +1,91 @@ +# +# 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. +# + +"""Tests for security.services.authenticator module.""" + +import pytest + +from security.services.authenticator import AquaSecAuthenticator + + +# _generate_signature + + +def test_generate_signature_returns_hex_string(): + authenticator = AquaSecAuthenticator("key", "test_secret", "1234") + + actual = authenticator._generate_signature("test_string") + + assert isinstance(actual, str) + assert 64 == len(actual) + + +# authenticate + + +def test_authenticate_returns_bearer_token(mocker): + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": "bearer_token_123"} + mocker.patch("security.services.authenticator.requests.post", return_value=mock_response) + + actual = AquaSecAuthenticator("test_key", "test_secret", "1234").authenticate() + + assert "bearer_token_123" == actual + + +def test_authenticate_raises_system_exit_on_non_200_status(mocker): + mock_response = mocker.Mock() + mock_response.status_code = 403 + mock_response.text = "Access denied" + mocker.patch("security.services.authenticator.requests.post", return_value=mock_response) + + with pytest.raises(SystemExit, match="Status 403"): + AquaSecAuthenticator("test_key", "test_secret", "1234").authenticate() + + +def test_authenticate_raises_system_exit_when_token_missing(mocker): + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": ""} + mocker.patch("security.services.authenticator.requests.post", return_value=mock_response) + + with pytest.raises(SystemExit, match="missing bearer token"): + AquaSecAuthenticator("test_key", "test_secret", "1234").authenticate() + + +def test_authenticate_uses_any_wildcard_endpoint(mocker): + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": "bearer_token_123"} + mock_post = mocker.patch("security.services.authenticator.requests.post", return_value=mock_response) + + AquaSecAuthenticator("test_key", "test_secret", "1234").authenticate() + + post_body = mock_post.call_args[1]["data"] + assert '"ANY:*"' in post_body + + +def test_authenticate_raises_system_exit_on_request_exception(mocker): + import requests + + mocker.patch( + "security.services.authenticator.requests.post", + side_effect=requests.RequestException("Connection failed"), + ) + + with pytest.raises(SystemExit, match="request failed"): + AquaSecAuthenticator("test_key", "test_secret", "1234").authenticate() diff --git a/tests/security/services/test_issue_syncer.py b/tests/security/services/test_issue_syncer.py new file mode 100644 index 0000000..1aaa49e --- /dev/null +++ b/tests/security/services/test_issue_syncer.py @@ -0,0 +1,87 @@ +# +# 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. +# + +"""Unit tests for ``security.services.issue_syncer``.""" + +import pytest +from unittest.mock import MagicMock + +from security.config import SecurityConfig +from security.services.issue_syncer import IssueSyncer + + +@pytest.fixture() +def config(): + """Minimal SecurityConfig for testing.""" + return SecurityConfig( + aqua_key="k", + aqua_secret="s", + aqua_group_id="g", + aqua_repository_id="00000000-0000-0000-0000-000000000000", + repo="org/repo", + issue_label="scope:security", + severity_priority_map="Critical=P0", + project_number=42, + project_org="org", + teams_webhook_url="https://hooks.example.com/webhook", + ) + + +# sync + + +def test_sync_calls_sync_alerts_and_issues(mocker, config): + mocker.patch("security.services.issue_syncer.gh_issue_list_by_label", return_value={}) + mock_sync = mocker.patch("security.services.issue_syncer.sync_alerts_and_issues") + mock_sync.return_value = MagicMock(notifications=[], severity_changes=[]) + + syncer = IssueSyncer(config) + syncer.sync({}, dry_run=False) + + mock_sync.assert_called_once() + + +def test_sync_passes_dry_run(mocker, config): + mocker.patch("security.services.issue_syncer.gh_issue_list_by_label", return_value={}) + mock_sync = mocker.patch("security.services.issue_syncer.sync_alerts_and_issues") + mock_sync.return_value = MagicMock(notifications=[], severity_changes=[]) + + syncer = IssueSyncer(config) + syncer.sync({}, dry_run=True) + + _, kwargs = mock_sync.call_args + assert kwargs["dry_run"] is True + + +def test_sync_returns_sync_result(mocker, config): + mocker.patch("security.services.issue_syncer.gh_issue_list_by_label", return_value={}) + expected = MagicMock(notifications=["n"], severity_changes=["s"]) + mocker.patch("security.services.issue_syncer.sync_alerts_and_issues", return_value=expected) + + syncer = IssueSyncer(config) + result = syncer.sync({}, dry_run=False) + + assert result is expected + + +def test_sync_does_not_send_notifications(mocker, config): + mocker.patch("security.services.issue_syncer.gh_issue_list_by_label", return_value={}) + mocker.patch("security.services.issue_syncer.sync_alerts_and_issues", return_value=MagicMock(notifications=["n"], severity_changes=[])) + + syncer = IssueSyncer(config) + result = syncer.sync({}, dry_run=False) + + assert result is not None diff --git a/tests/security/test_check_labels.py b/tests/security/services/test_label_checker.py similarity index 50% rename from tests/security/test_check_labels.py rename to tests/security/services/test_label_checker.py index 78975bf..7ca2e8c 100644 --- a/tests/security/test_check_labels.py +++ b/tests/security/services/test_label_checker.py @@ -14,7 +14,7 @@ # limitations under the License. # -"""Unit tests for ``check_labels.py``.""" +"""Unit tests for ``security.services.label_checker``.""" import json import subprocess @@ -22,69 +22,69 @@ import pytest from pytest_mock import MockerFixture -from security.check_labels import REQUIRED_LABELS, check_labels, fetch_repo_labels, main +from security.constants import REQUIRED_LABELS +from security.services.label_checker import LabelChecker REPO = "my-org/my-repo" def _gh_result(labels: list[str]) -> subprocess.CompletedProcess: - """Build a fake ``run_gh`` return value.""" payload = json.dumps([{"name": n} for n in labels]) return subprocess.CompletedProcess(args=[], returncode=0, stdout=payload, stderr="") -def test_fetch_repo_labels_returns_names(mocker: MockerFixture) -> None: - mock_gh = mocker.patch("security.check_labels.run_gh", return_value=_gh_result(["scope:security", "epic"])) - assert fetch_repo_labels(REPO) == ["scope:security", "epic"] +# _fetch_labels + + +def test_fetch_labels_returns_names(mocker: MockerFixture) -> None: + mock_gh = mocker.patch("security.services.label_checker.run_gh", return_value=_gh_result(["scope:security", "epic"])) + checker = LabelChecker(REPO) + assert checker._fetch_labels() == ["scope:security", "epic"] mock_gh.assert_called_once_with( ["label", "list", "--repo", REPO, "--json", "name", "--limit", "500"], ) -def test_fetch_repo_labels_skips_empty_names(mocker: MockerFixture) -> None: +def test_fetch_labels_skips_empty_names(mocker: MockerFixture) -> None: payload = json.dumps([{"name": "good"}, {"name": ""}, {}]) mocker.patch( - "security.check_labels.run_gh", + "security.services.label_checker.run_gh", return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout=payload, stderr=""), ) - assert fetch_repo_labels(REPO) == ["good"] + assert LabelChecker(REPO)._fetch_labels() == ["good"] + + +def test_fetch_labels_raises_on_gh_failure(mocker: MockerFixture) -> None: + mocker.patch( + "security.services.label_checker.run_gh", + return_value=subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="error"), + ) + with pytest.raises(SystemExit): + LabelChecker(REPO)._fetch_labels() + + +# check_labels def test_check_labels_all_present(mocker: MockerFixture) -> None: - mocker.patch("security.check_labels.fetch_repo_labels", return_value=list(REQUIRED_LABELS) + ["extra-label"]) - assert check_labels(REPO) == [] + mocker.patch.object(LabelChecker, "_fetch_labels", return_value=list(REQUIRED_LABELS) + ["extra"]) + assert LabelChecker(REPO).check_labels() == [] def test_check_labels_some_missing(mocker: MockerFixture) -> None: - mocker.patch("security.check_labels.fetch_repo_labels", return_value=["scope:security", "epic"]) - missing = check_labels(REPO) + mocker.patch.object(LabelChecker, "_fetch_labels", return_value=["scope:security", "epic"]) + missing = LabelChecker(REPO).check_labels() assert "type:tech-debt" in missing assert "sec:adept-to-close" in missing assert len(missing) == 2 def test_check_labels_all_missing(mocker: MockerFixture) -> None: - mocker.patch("security.check_labels.fetch_repo_labels", return_value=[]) - assert check_labels(REPO) == list(REQUIRED_LABELS) + mocker.patch.object(LabelChecker, "_fetch_labels", return_value=[]) + assert LabelChecker(REPO).check_labels() == list(REQUIRED_LABELS) def test_check_labels_custom_required(mocker: MockerFixture) -> None: - mocker.patch("security.check_labels.fetch_repo_labels", return_value=["a"]) - assert check_labels(REPO, required=["a", "b"]) == ["b"] - - -def test_main_success(mocker: MockerFixture) -> None: - mock_check = mocker.patch("security.check_labels.check_labels", return_value=[]) - assert main(["--repo", REPO]) == 0 - mock_check.assert_called_once_with(REPO) - - -def test_main_failure(mocker: MockerFixture) -> None: - mocker.patch("security.check_labels.check_labels", return_value=["epic"]) - assert main(["--repo", REPO]) == 1 - - -def test_main_missing_repo() -> None: - with pytest.raises(SystemExit): - main([]) + mocker.patch.object(LabelChecker, "_fetch_labels", return_value=["a"]) + assert LabelChecker(REPO, required=["a", "b"]).check_labels() == ["b"] diff --git a/tests/security/services/test_notification_sender.py b/tests/security/services/test_notification_sender.py new file mode 100644 index 0000000..d6a48b4 --- /dev/null +++ b/tests/security/services/test_notification_sender.py @@ -0,0 +1,201 @@ +# +# 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. +# + +"""Tests for security.services.notification_sender module.""" + +import pytest +from unittest.mock import MagicMock + +from security.issues.models import NotifiedIssue, SeverityChange +from security.services.notification_sender import NotificationSender + + +@pytest.fixture +def sample_notifications() -> list[NotifiedIssue]: + return [ + NotifiedIssue(repo="org/repo-a", issue_number=10, severity="high", category="sast", state="new", tool="AquaSec"), + NotifiedIssue(repo="org/repo-b", issue_number=20, severity="medium", category="sca", state="reopen", tool="AquaSec"), + ] + + +@pytest.fixture +def sample_changes() -> list[SeverityChange]: + return [ + SeverityChange(repo="org/repo-a", issue_number=5, rule_id="CVE-2026-1234", old_severity="medium", new_severity="critical"), + ] + + +# _build_payload + + +def test_build_payload_without_title(): + payload = NotificationSender._build_payload("Hello **world**") + + assert "message" == payload["type"] + card = payload["attachments"][0]["content"] + assert "AdaptiveCard" == card["type"] + assert 1 == len(card["body"]) + assert "Hello **world**" == card["body"][0]["items"][0]["text"] + + +def test_build_payload_with_title_and_subtitle(): + payload = NotificationSender._build_payload("Body text", title="My Title", subtitle="Sub") + + card = payload["attachments"][0]["content"] + assert 2 == len(card["body"]) + header = card["body"][0] + assert "accent" == header["style"] + assert "My Title" == header["items"][0]["text"] + assert "Sub" == header["items"][1]["text"] + + +# _build_issues_body + + +def test_build_issues_body_counts(sample_notifications): + body = NotificationSender._build_issues_body(sample_notifications) + assert "**1** new" in body + assert "**1** reopened" in body + + +def test_build_issues_body_contains_links(sample_notifications): + body = NotificationSender._build_issues_body(sample_notifications) + assert "https://github.com/org/repo-a/issues/10" in body + assert "https://github.com/org/repo-b/issues/20" in body + + +def test_build_issues_body_state_tags(sample_notifications): + body = NotificationSender._build_issues_body(sample_notifications) + assert "[new]" in body + assert "[reopen]" in body + + +def test_build_issues_body_pending_issue_number_zero(): + n = NotifiedIssue(repo="org/repo", issue_number=0, severity="high", category="sast", state="new", tool="AquaSec") + body = NotificationSender._build_issues_body([n]) + assert "(pending)" in body + assert "issues/0" not in body + + +# _build_severity_body + + +def test_build_severity_body_counts(sample_changes): + body = NotificationSender._build_severity_body(sample_changes) + assert "**1** parent issue(s)" in body + + +def test_build_severity_body_contains_link(sample_changes): + body = NotificationSender._build_severity_body(sample_changes) + assert "https://github.com/org/repo-a/issues/5" in body + + +def test_build_severity_body_direction(sample_changes): + body = NotificationSender._build_severity_body(sample_changes) + assert "escalated" in body + + +def test_build_severity_body_severities(sample_changes): + body = NotificationSender._build_severity_body(sample_changes) + assert "**medium**" in body + assert "**critical**" in body + + +def test_build_severity_body_rule_id(sample_changes): + body = NotificationSender._build_severity_body(sample_changes) + assert "CVE-2026-1234" in body + + +# notify + + +def test_notify_calls_both_dispatchers(mocker, sample_notifications, sample_changes): + result = MagicMock(notifications=sample_notifications, severity_changes=sample_changes) + mock_issues = mocker.patch.object(NotificationSender, "_notify_issues") + mock_sev = mocker.patch.object(NotificationSender, "_notify_severity_changes") + + NotificationSender("https://hook").notify(result, dry_run=False) + + mock_issues.assert_called_once_with(sample_notifications, dry_run=False) + mock_sev.assert_called_once_with(sample_changes, dry_run=False) + + +def test_notify_skips_when_no_webhook(mocker): + result = MagicMock(notifications=["n"], severity_changes=[]) + mock_issues = mocker.patch.object(NotificationSender, "_notify_issues") + + NotificationSender("").notify(result, dry_run=False) + + mock_issues.assert_not_called() + + +def test_notify_dry_run_passed_through(mocker, sample_notifications): + result = MagicMock(notifications=sample_notifications, severity_changes=[]) + mock_issues = mocker.patch.object(NotificationSender, "_notify_issues") + mocker.patch.object(NotificationSender, "_notify_severity_changes") + + NotificationSender("https://hook").notify(result, dry_run=True) + + _, kwargs = mock_issues.call_args + assert kwargs["dry_run"] is True + + +# send + + +def test_send_posts_to_webhook(mocker): + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.text = "1" + mock_post = mocker.patch("security.services.notification_sender.requests.post", return_value=mock_response) + + NotificationSender("https://hook.example.com").send("Test body", title="Title") + + mock_post.assert_called_once() + assert "https://hook.example.com" == mock_post.call_args[0][0] + + +def test_send_raises_system_exit_on_non_200(mocker): + mock_response = mocker.Mock() + mock_response.status_code = 500 + mock_response.text = "Internal error" + mocker.patch("security.services.notification_sender.requests.post", return_value=mock_response) + + with pytest.raises(SystemExit, match="webhook request failed"): + NotificationSender("https://hook.example.com").send("Test body") + + +def test_send_raises_system_exit_on_request_exception(mocker): + import requests + + mocker.patch( + "security.services.notification_sender.requests.post", + side_effect=requests.RequestException("Connection failed"), + ) + + with pytest.raises(SystemExit, match="webhook request failed"): + NotificationSender("https://hook.example.com").send("Test body") + + +# send_dry_run + + +def test_send_dry_run_does_not_post(mocker): + mock_post = mocker.patch("security.services.notification_sender.requests.post") + + NotificationSender("https://hook.example.com").send_dry_run("Test body", title="Title") + + mock_post.assert_not_called() diff --git a/tests/security/services/test_scan_fetcher.py b/tests/security/services/test_scan_fetcher.py new file mode 100644 index 0000000..b4d07bc --- /dev/null +++ b/tests/security/services/test_scan_fetcher.py @@ -0,0 +1,124 @@ +# +# 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. +# + +"""Tests for security.services.scan_fetcher module.""" + +import json + +import pytest + +from security.services.scan_fetcher import ScanFetcher + + +# fetch_findings + + +def test_fetch_findings_returns_single_page_results(mocker): + fetcher = ScanFetcher("test_token", "abc12345-e89b-12d3-a456-426614174000") + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"total": 2, "data": [{"id": 1}, {"id": 2}]} + mocker.patch("security.services.scan_fetcher.requests.get", return_value=mock_response) + + actual = fetcher.fetch_findings() + + assert 2 == actual["total"] + assert 2 == len(actual["data"]) + assert {"id": 1} == actual["data"][0] + + +def test_fetch_findings_returns_multi_page_results(mocker): + fetcher = ScanFetcher("test_token", "abc12345-e89b-12d3-a456-426614174000") + + mock_response_page1 = mocker.Mock() + mock_response_page1.status_code = 200 + mock_response_page1.json.return_value = {"total": 3, "data": [{"id": 1}, {"id": 2}]} + + mock_response_page2 = mocker.Mock() + mock_response_page2.status_code = 200 + mock_response_page2.json.return_value = {"total": 3, "data": [{"id": 3}]} + + mocker.patch("security.services.scan_fetcher.requests.get", side_effect=[mock_response_page1, mock_response_page2]) + mocker.patch("security.services.scan_fetcher.time.sleep") + + actual = fetcher.fetch_findings() + + assert 3 == actual["total"] + assert 3 == len(actual["data"]) + + +def test_fetch_findings_returns_empty_results(mocker): + fetcher = ScanFetcher("test_token", "abc12345-e89b-12d3-a456-426614174000") + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"total": 0, "data": []} + mocker.patch("security.services.scan_fetcher.requests.get", return_value=mock_response) + + actual = fetcher.fetch_findings() + + assert 0 == actual["total"] + assert 0 == len(actual["data"]) + + +def test_fetch_findings_raises_system_exit_on_non_200_status(mocker): + fetcher = ScanFetcher("test_token", "abc12345-e89b-12d3-a456-426614174000") + mock_response = mocker.Mock() + mock_response.status_code = 403 + mock_response.text = "Access denied" + mocker.patch("security.services.scan_fetcher.requests.get", return_value=mock_response) + + with pytest.raises(SystemExit, match="Status 403"): + fetcher.fetch_findings() + + +def test_fetch_findings_raises_system_exit_on_invalid_json(mocker): + fetcher = ScanFetcher("test_token", "abc12345-e89b-12d3-a456-426614174000") + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "doc", 0) + mocker.patch("security.services.scan_fetcher.requests.get", return_value=mock_response) + + with pytest.raises(SystemExit, match="Invalid JSON"): + fetcher.fetch_findings() + + +def test_fetch_findings_uses_correct_request_structure(mocker): + fetcher = ScanFetcher("test_token_123", "abc12345-e89b-12d3-a456-426614174000") + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"total": 0, "data": []} + mock_get = mocker.patch("security.services.scan_fetcher.requests.get", return_value=mock_response) + + fetcher.fetch_findings() + + call_args = mock_get.call_args + assert "Bearer test_token_123" == call_args[1]["headers"]["Authorization"] + assert "abc12345-e89b-12d3-a456-426614174000" in call_args[0][0] + assert "size=100" in call_args[0][0] + assert "page=1" in call_args[0][0] + + +def test_fetch_findings_raises_system_exit_on_request_exception(mocker): + import requests + + fetcher = ScanFetcher("test_token", "abc12345-e89b-12d3-a456-426614174000") + mocker.patch( + "security.services.scan_fetcher.requests.get", + side_effect=requests.RequestException("Connection failed"), + ) + + with pytest.raises(SystemExit, match="request failed"): + fetcher.fetch_findings() diff --git a/tests/security/test_collect_alert.py b/tests/security/test_collect_alert.py deleted file mode 100644 index 88c157f..0000000 --- a/tests/security/test_collect_alert.py +++ /dev/null @@ -1,507 +0,0 @@ -# -# 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. -# - -"""Unit tests for ``collect_alert.py``.""" - -import json -import subprocess - -import pytest -from pytest_mock import MockerFixture - -from security.collect_alert import ( - RULE_DETAIL_KEYS, - VALID_STATES, - _gh_api_json, - _gh_api_paginate, - _normalise_alert, - _parse_alert_details, - _parse_rule_details, - _snake_case, - _help_value, - _help_multiline_value, - main, - parse_args, -) - - -REPO = "my-org/my-repo" - -_RAW_ALERT: dict = { - "number": 303, - "state": "open", - "created_at": "2026-02-25T08:25:18Z", - "updated_at": "2026-02-25T14:11:06Z", - "url": "https://api.github.com/repos/org/repo/code-scanning/alerts/303", - "html_url": "https://github.com/org/repo/security/code-scanning/303", - "rule": { - "id": "rule-1", - "name": "sast", - "description": "Requests with verify=False", - "security_severity_level": "high", - "severity": "error", - "tags": ["HIGH", "sast"], - "help_uri": "https://example.com", - "help": "**Type:** sast\n**Severity:** HIGH\n**Impact:** medium", - }, - "tool": {"name": "AquaSec", "version": "1.0.0"}, - "most_recent_instance": { - "ref": "refs/heads/master", - "commit_sha": "abc123", - "html_url": "https://github.com/org/repo/instance/1", - "classifications": ["library"], - "location": {"path": "src/main.py", "start_line": 10, "end_line": 20}, - "message": {"text": "Type: sast\nSeverity: HIGH\nAlert hash: abc123hash"}, - }, -} - - -def _mock_happy_path(mocker: MockerFixture, repo_data: dict | None = None, raw_alerts: list | None = None): - """Set up mocks for a successful main() run.""" - mocker.patch("security.collect_alert.shutil.which", return_value="/usr/bin/gh") - mocker.patch( - "security.collect_alert.run_gh", - return_value=_gh_ok("Logged in"), - ) - mocker.patch( - "security.collect_alert._gh_api_json", - return_value=repo_data or { - "id": 1, - "name": "my-repo", - "full_name": "my-org/my-repo", - "private": False, - "html_url": "https://github.com/my-org/my-repo", - "default_branch": "main", - "owner": {"login": "my-org", "id": 100, "html_url": "https://github.com/my-org"}, - }, - ) - mocker.patch( - "security.collect_alert._gh_api_paginate", - return_value=raw_alerts if raw_alerts is not None else [], - ) - - -def _gh_ok(stdout: str) -> subprocess.CompletedProcess: - """Build a successful ``run_gh`` return value.""" - return subprocess.CompletedProcess(args=[], returncode=0, stdout=stdout, stderr="") - - -def _gh_fail(stderr: str = "error") -> subprocess.CompletedProcess: - """Build a failed ``run_gh`` return value.""" - return subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr=stderr) - - -def test_snake_case_simple() -> None: - assert _snake_case("Fixed version") == "fixed_version" - - -def test_snake_case_single_word() -> None: - assert _snake_case("Severity") == "severity" - - -def test_snake_case_strips_whitespace() -> None: - assert _snake_case(" Package name ") == "package_name" - - -def test_snake_case_already_lower() -> None: - assert _snake_case("impact") == "impact" - - -def test_help_value_found() -> None: - text = "Some preamble\n**Severity:** HIGH\nMore text" - assert _help_value(text, "Severity") == "HIGH" - - -def test_help_value_not_found() -> None: - assert _help_value("no match here", "Severity") is None - - -def test_help_value_case_insensitive() -> None: - text = "**severity:** medium" - assert _help_value(text, "Severity") == "medium" - - -def test_help_value_with_extra_spacing() -> None: - text = "**CWE:** CWE-79" - assert _help_value(text, "CWE") == "CWE-79" - - -def test_help_value_stops_at_newline() -> None: - text = "**Type:** sast\n**Severity:** HIGH" - assert _help_value(text, "Type") == "sast" - - -# _help_multiline_value - - -def test_help_multiline_value_single_line() -> None: - text = "**References:** https://example.com" - assert _help_multiline_value(text, "References") == "https://example.com" - - -def test_help_multiline_value_bullet_list() -> None: - text = ( - "**References:**\n" - "- https://example.com/ref1\n" - "- https://example.com/ref2\n" - "**Type:** sast" - ) - result = _help_multiline_value(text, "References") - assert "https://example.com/ref1" in result - assert "https://example.com/ref2" in result - - -def test_help_multiline_value_not_found() -> None: - assert _help_multiline_value("no match here", "References") is None - - -def test_help_multiline_value_none_input() -> None: - assert _help_multiline_value(None, "References") is None - - -def test_help_multiline_value_empty_value() -> None: - text = "**References:**\n**Type:** sast" - assert _help_multiline_value(text, "References") is None - - -def test_parse_rule_details_multiline_references() -> None: - rule_help = ( - "**Type:** sast\n" - "**References:**\n" - "- https://example.com/ref1\n" - "- https://example.com/ref2\n" - "**Severity:** HIGH" - ) - details = _parse_rule_details(rule_help) - assert "https://example.com/ref1" in details["references"] - assert "https://example.com/ref2" in details["references"] - - -def test_parse_rule_details_extracts_fields() -> None: - rule_help = ( - "**Type:** sast\n" - "**Severity:** HIGH\n" - "**CWE:** CWE-295\n" - "**Impact:** medium\n" - "**Confidence:** high\n" - "**Likelihood:** medium\n" - ) - details = _parse_rule_details(rule_help) - assert details["type"] == "sast" - assert details["severity"] == "HIGH" - assert details["cwe"] == "CWE-295" - assert details["impact"] == "medium" - assert details["confidence"] == "high" - assert details["likelihood"] == "medium" - - -def test_parse_rule_details_missing_fields_are_none() -> None: - details = _parse_rule_details("") - assert all(details[_snake_case(k)] is None for k in RULE_DETAIL_KEYS) - - -def test_parse_rule_details_returns_all_keys() -> None: - details = _parse_rule_details("") - assert set(details.keys()) == {_snake_case(k) for k in RULE_DETAIL_KEYS} - - -def test_parse_alert_details_basic() -> None: - message = ( - "Artifact: src/main.py\n" - "Type: sast\n" - "Severity: HIGH\n" - "Message: Something bad\n" - ) - details = _parse_alert_details(message) - assert details["artifact"] == "src/main.py" - assert details["type"] == "sast" - assert details["severity"] == "HIGH" - assert details["message"] == "Something bad" - - -def test_parse_alert_details_empty_input() -> None: - assert _parse_alert_details("") == {} - - -def test_parse_alert_details_no_matching_lines() -> None: - assert _parse_alert_details("just some text without colons\nanother line") == {} - - -def test_parse_alert_details_multiword_key() -> None: - message = "Start line: 42\nEnd line: 50\n" - details = _parse_alert_details(message) - assert details["start_line"] == "42" - assert details["end_line"] == "50" - - -def test_parse_alert_details_strips_carriage_return() -> None: - message = "Type: sast\r\nSeverity: HIGH\r\n" - details = _parse_alert_details(message) - assert details["type"] == "sast" - assert details["severity"] == "HIGH" - - -def test_parse_alert_details_value_with_colon() -> None: - message = "SCM file: https://github.com/org/repo/blob/abc/file.py\n" - details = _parse_alert_details(message) - assert details["scm_file"] == "https://github.com/org/repo/blob/abc/file.py" - - -def test_gh_api_json_success(mocker: MockerFixture) -> None: - payload = {"id": 123, "name": "my-repo"} - mocker.patch("security.collect_alert.run_gh", return_value=_gh_ok(json.dumps(payload))) - assert _gh_api_json("/repos/my-org/my-repo") == payload - - -def test_gh_api_json_failure_exits(mocker: MockerFixture) -> None: - mocker.patch("security.collect_alert.run_gh", return_value=_gh_fail("not found")) - with pytest.raises(SystemExit): - _gh_api_json("/repos/my-org/my-repo") - - -def test_gh_api_paginate_single_page(mocker: MockerFixture) -> None: - alerts = [{"number": 1}, {"number": 2}] - mocker.patch("security.collect_alert.run_gh", return_value=_gh_ok(json.dumps(alerts))) - assert _gh_api_paginate("/repos/org/repo/alerts") == alerts - - -def test_gh_api_paginate_multiple_pages(mocker: MockerFixture) -> None: - page1 = json.dumps([{"number": 1}]) - page2 = json.dumps([{"number": 2}]) - stdout = page1 + "\n" + page2 - mocker.patch("security.collect_alert.run_gh", return_value=_gh_ok(stdout)) - result = _gh_api_paginate("/repos/org/repo/alerts") - assert result == [{"number": 1}, {"number": 2}] - - -def test_gh_api_paginate_single_object(mocker: MockerFixture) -> None: - mocker.patch("security.collect_alert.run_gh", return_value=_gh_ok(json.dumps({"key": "val"}))) - result = _gh_api_paginate("/endpoint") - assert result == [{"key": "val"}] - - -def test_gh_api_paginate_empty_array(mocker: MockerFixture) -> None: - mocker.patch("security.collect_alert.run_gh", return_value=_gh_ok("[]")) - assert _gh_api_paginate("/endpoint") == [] - - -def test_gh_api_paginate_failure_exits(mocker: MockerFixture) -> None: - mocker.patch("security.collect_alert.run_gh", return_value=_gh_fail("error")) - with pytest.raises(SystemExit): - _gh_api_paginate("/endpoint") - - -def test_normalise_alert_metadata() -> None: - result = _normalise_alert(_RAW_ALERT) - meta = result["metadata"] - assert meta["alert_number"] == 303 - assert meta["state"] == "open" - assert meta["rule_id"] == "rule-1" - assert meta["rule_name"] == "sast" - assert meta["rule_description"] == "Requests with verify=False" - assert meta["severity"] == "high" - assert meta["confidence"] == "error" - assert meta["tags"] == ["HIGH", "sast"] - assert meta["tool"] == "AquaSec" - assert meta["tool_version"] == "1.0.0" - assert meta["ref"] == "refs/heads/master" - assert meta["commit_sha"] == "abc123" - assert meta["file"] == "src/main.py" - assert meta["start_line"] == 10 - assert meta["end_line"] == 20 - assert meta["classifications"] == ["library"] - - -def test_normalise_alert_alert_details() -> None: - result = _normalise_alert(_RAW_ALERT) - ad = result["alert_details"] - assert ad["type"] == "sast" - assert ad["severity"] == "HIGH" - assert ad["alert_hash"] == "abc123hash" - - -def test_normalise_alert_rule_details() -> None: - result = _normalise_alert(_RAW_ALERT) - rd = result["rule_details"] - assert rd["type"] == "sast" - assert rd["severity"] == "HIGH" - assert rd["impact"] == "medium" - - -def test_normalise_alert_minimal() -> None: - result = _normalise_alert({}) - meta = result["metadata"] - assert meta["alert_number"] is None - assert meta["state"] is None - assert meta["rule_id"] is None - assert meta["rule_description"] is None - assert meta["tool"] is None - assert meta["file"] is None - assert meta["tags"] == [] - assert meta["classifications"] == [] - assert result["alert_details"] == {} - - -def test_normalise_alert_missing_message() -> None: - alert = { - "most_recent_instance": {"message": None}, - } - result = _normalise_alert(alert) - assert result["alert_details"] == {} - - -def test_normalise_alert_missing_rule_help() -> None: - alert = {"rule": {"help": None}} - result = _normalise_alert(alert) - assert all(v is None for v in result["rule_details"].values()) - - -def test_parse_args_defaults() -> None: - args = parse_args(["--repo", REPO]) - assert args.repo == REPO - assert args.state == "open" - assert args.out_file == "alerts.json" - assert args.verbose is False - - -def test_parse_args_all_options() -> None: - args = parse_args(["--repo", REPO, "--state", "dismissed", "--out", "out.json", "--verbose"]) - assert args.state == "dismissed" - assert args.out_file == "out.json" - assert args.verbose is True - - -def test_parse_args_state_choices() -> None: - for state in sorted(VALID_STATES): - args = parse_args(["--repo", REPO, "--state", state]) - assert args.state == state - - -def test_parse_args_invalid_state_rejected() -> None: - with pytest.raises(SystemExit): - parse_args(["--repo", REPO, "--state", "bogus"]) - - -def test_parse_args_repo_required() -> None: - with pytest.raises(SystemExit): - parse_args([]) - - -def test_main_writes_json(mocker: MockerFixture, tmp_path) -> None: - _mock_happy_path(mocker) - out = str(tmp_path / "alerts.json") - main(["--repo", REPO, "--out", out]) - data = json.loads((tmp_path / "alerts.json").read_text()) - assert data["repo"]["full_name"] == "my-org/my-repo" - assert data["query"]["state"] == "open" - assert data["alerts"] == [] - assert "generated_at" in data - - -def test_main_writes_normalised_alerts(mocker: MockerFixture, tmp_path) -> None: - _mock_happy_path(mocker, raw_alerts=[_RAW_ALERT]) - out = str(tmp_path / "alerts.json") - main(["--repo", REPO, "--out", out]) - data = json.loads((tmp_path / "alerts.json").read_text()) - assert len(data["alerts"]) == 1 - assert data["alerts"][0]["metadata"]["alert_number"] == 303 - - -def test_main_repo_metadata_in_output(mocker: MockerFixture, tmp_path) -> None: - _mock_happy_path(mocker) - out = str(tmp_path / "alerts.json") - main(["--repo", REPO, "--out", out]) - data = json.loads((tmp_path / "alerts.json").read_text()) - repo_section = data["repo"] - assert repo_section["id"] == 1 - assert repo_section["name"] == "my-repo" - assert repo_section["private"] is False - assert repo_section["default_branch"] == "main" - assert repo_section["owner"]["login"] == "my-org" - - -def test_main_state_forwarded_to_paginate(mocker: MockerFixture, tmp_path) -> None: - _mock_happy_path(mocker) - mock_paginate = mocker.patch("security.collect_alert._gh_api_paginate", return_value=[]) - out = str(tmp_path / "alerts.json") - main(["--repo", REPO, "--state", "dismissed", "--out", out]) - endpoint = mock_paginate.call_args[0][0] - assert "state=dismissed" in endpoint - - -def test_main_state_all_omits_state_param(mocker: MockerFixture, tmp_path) -> None: - _mock_happy_path(mocker) - mock_paginate = mocker.patch("security.collect_alert._gh_api_paginate", return_value=[]) - out = str(tmp_path / "alerts.json") - main(["--repo", REPO, "--state", "all", "--out", out]) - endpoint = mock_paginate.call_args[0][0] - assert "state=" not in endpoint - - -def test_main_invalid_repo_format_exits(mocker: MockerFixture, tmp_path) -> None: - mocker.patch("security.collect_alert.shutil.which", return_value="/usr/bin/gh") - mocker.patch("security.collect_alert.run_gh", return_value=_gh_ok("ok")) - out = str(tmp_path / "alerts.json") - with pytest.raises(SystemExit): - main(["--repo", "noslash", "--out", out]) - - -def test_main_gh_not_found_exits(mocker: MockerFixture, tmp_path) -> None: - mocker.patch("security.collect_alert.shutil.which", return_value=None) - out = str(tmp_path / "alerts.json") - with pytest.raises(SystemExit): - main(["--repo", REPO, "--out", out]) - - -def test_main_gh_not_authenticated_exits(mocker: MockerFixture, tmp_path) -> None: - mocker.patch("security.collect_alert.shutil.which", return_value="/usr/bin/gh") - mocker.patch("security.collect_alert.run_gh", return_value=_gh_fail("not logged in")) - out = str(tmp_path / "alerts.json") - with pytest.raises(SystemExit): - main(["--repo", REPO, "--out", out]) - - -def test_main_refuses_overwrite(mocker: MockerFixture, tmp_path) -> None: - mocker.patch("security.collect_alert.shutil.which", return_value="/usr/bin/gh") - mocker.patch("security.collect_alert.run_gh", return_value=_gh_ok("ok")) - out = tmp_path / "alerts.json" - out.write_text("{}") - with pytest.raises(SystemExit): - main(["--repo", REPO, "--out", str(out)]) - - -def test_main_verbose_via_flag(mocker: MockerFixture, tmp_path) -> None: - _mock_happy_path(mocker) - mock_setup = mocker.patch("security.collect_alert.setup_logging") - out = str(tmp_path / "alerts.json") - main(["--repo", REPO, "--out", out, "--verbose"]) - mock_setup.assert_called_once_with(True) - - -def test_main_verbose_via_runner_debug(mocker: MockerFixture, tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("RUNNER_DEBUG", "1") - _mock_happy_path(mocker) - mock_setup = mocker.patch("security.collect_alert.setup_logging") - out = str(tmp_path / "alerts.json") - main(["--repo", REPO, "--out", out]) - mock_setup.assert_called_once_with(True) - - -def test_main_output_ends_with_newline(mocker: MockerFixture, tmp_path) -> None: - _mock_happy_path(mocker) - out = tmp_path / "alerts.json" - main(["--repo", REPO, "--out", str(out)]) - assert out.read_text().endswith("\n") diff --git a/tests/security/test_config.py b/tests/security/test_config.py new file mode 100644 index 0000000..a53bec4 --- /dev/null +++ b/tests/security/test_config.py @@ -0,0 +1,156 @@ +# +# 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. +# + +"""Tests for security.config module.""" + +import argparse + +import pytest + +from security.config import SecurityConfig + + +def _make_args(**kwargs) -> argparse.Namespace: + """Create a minimal argparse Namespace for SecurityConfig.load().""" + defaults = { + "repo": "my-org/my-repo", + "dry_run": False, + "verbose": False, + "issue_label": "scope:security", + "severity_priority_map": "", + "project_number": "", + "project_org": "", + "teams_webhook_url": "", + } + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +def _make_config(**kwargs) -> SecurityConfig: + """Create a valid SecurityConfig with overridable fields.""" + defaults = { + "aqua_key": "test-key", + "aqua_secret": "test-secret", + "aqua_group_id": "12345", + "aqua_repository_id": "abc12345-e89b-12d3-a456-426614174000", + "repo": "my-org/my-repo", + } + defaults.update(kwargs) + return SecurityConfig(**defaults) + + +# validate + + +def test_validate_passes_with_valid_config(): + config = _make_config() + config.validate() + + +def test_validate_raises_system_exit_when_aqua_key_missing(): + config = _make_config(aqua_key="") + + with pytest.raises(SystemExit): + config.validate() + + +def test_validate_raises_system_exit_when_aqua_secret_missing(): + config = _make_config(aqua_secret="") + + with pytest.raises(SystemExit): + config.validate() + + +def test_validate_raises_system_exit_when_group_id_missing(): + config = _make_config(aqua_group_id="") + + with pytest.raises(SystemExit): + config.validate() + + +def test_validate_raises_system_exit_when_repository_id_missing(): + config = _make_config(aqua_repository_id="") + + with pytest.raises(SystemExit): + config.validate() + + +def test_validate_raises_system_exit_when_repository_id_invalid_uuid(): + config = _make_config(aqua_repository_id="not-a-uuid") + + with pytest.raises(SystemExit): + config.validate() + + +def test_validate_raises_system_exit_when_repo_missing(): + config = _make_config(repo="") + + with pytest.raises(SystemExit): + config.validate() + + +def test_validate_raises_system_exit_when_repo_no_slash(): + config = _make_config(repo="noslash") + + with pytest.raises(SystemExit): + config.validate() + + +# load + + +def test_load_reads_env_vars(monkeypatch): + monkeypatch.setenv("AQUA_KEY", "env-key") + monkeypatch.setenv("AQUA_SECRET", "env-secret") + monkeypatch.setenv("AQUA_GROUP_ID", "env-group") + monkeypatch.setenv("AQUA_REPOSITORY_ID", "abc12345-e89b-12d3-a456-426614174000") + + config = SecurityConfig.load(_make_args()) + + assert "env-key" == config.aqua_key + assert "env-secret" == config.aqua_secret + assert "env-group" == config.aqua_group_id + assert "abc12345-e89b-12d3-a456-426614174000" == config.aqua_repository_id + + +def test_load_reads_repo_from_args(): + config = SecurityConfig.load(_make_args(repo="org/repo")) + + assert "org/repo" == config.repo + + +def test_load_falls_back_to_github_repository_env(monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", "env-org/env-repo") + monkeypatch.setenv("AQUA_KEY", "k") + monkeypatch.setenv("AQUA_SECRET", "s") + monkeypatch.setenv("AQUA_GROUP_ID", "g") + monkeypatch.setenv("AQUA_REPOSITORY_ID", "r") + + config = SecurityConfig.load(_make_args(repo="")) + + assert "env-org/env-repo" == config.repo + + +def test_load_reads_project_number_as_int(): + config = SecurityConfig.load(_make_args(project_number="42")) + + assert 42 == config.project_number + + +def test_load_handles_invalid_project_number(): + config = SecurityConfig.load(_make_args(project_number="not-a-number")) + + assert config.project_number is None diff --git a/tests/security/test_main.py b/tests/security/test_main.py index 1de6ac2..95d8bbe 100644 --- a/tests/security/test_main.py +++ b/tests/security/test_main.py @@ -19,302 +19,159 @@ import pytest from pytest_mock import MockerFixture -from security.main import VALID_STATES, _resolve_repo, main, parse_args +from security.main import main, parse_args +from security.services.label_checker import LabelChecker +from security.services.notification_sender import NotificationSender REPO = "my-org/my-repo" -def _run_promote(mocker: MockerFixture, tmp_path, extra_args: list[str] | None = None) -> list[str]: - """Helper: run main() with mocked pipeline and return the argv passed to promote_alerts.""" - mocker.patch("security.main.check_labels", return_value=[]) - mocker.patch("security.main.collect_alert_main") - mock_promote = mocker.patch("security.main.promote_alerts_main") - out = str(tmp_path / "alerts.json") - argv = ["--repo", REPO, "--out", out] + (extra_args or []) - main(argv) - return mock_promote.call_args[0][0] - - -def test_parse_args_defaults() -> None: +@pytest.fixture(autouse=True) +def _aqua_env(monkeypatch): + """Set required AquaSec env vars for all tests.""" + monkeypatch.setenv("AQUA_KEY", "test-key") + monkeypatch.setenv("AQUA_SECRET", "test-secret") + monkeypatch.setenv("AQUA_GROUP_ID", "12345") + monkeypatch.setenv("AQUA_REPOSITORY_ID", "abc12345-e89b-12d3-a456-426614174000") + + +def _mock_pipeline(mocker: MockerFixture): + """Mock external dependencies in the pipeline.""" + mocker.patch.object(LabelChecker, "check_labels", return_value=[]) + mock_auth = mocker.patch("security.main.AquaSecAuthenticator") + mock_auth.return_value.authenticate.return_value = "token" + mock_fetcher = mocker.patch("security.main.ScanFetcher") + mock_fetcher.return_value.fetch_findings.return_value = {"total": 0, "data": []} + mock_parser = mocker.patch("security.main.AquaSecParser") + mock_parser.return_value.parse.return_value = mocker.Mock(open_by_number={}) + mock_syncer = mocker.patch("security.main.IssueSyncer") + mock_syncer.return_value.sync.return_value = mocker.Mock() + mock_notifier = mocker.patch.object(NotificationSender, "notify") + return { + "auth": mock_auth, + "fetcher": mock_fetcher, + "parser": mock_parser, + "syncer": mock_syncer, + "notifier": mock_notifier, + } + + +# parse_args + + +def test_parse_args_defaults(): args = parse_args(["--repo", REPO]) assert args.repo == REPO - assert args.state == "open" - assert args.out_file == "alerts.json" assert args.issue_label == "scope:security" assert args.dry_run is False assert args.verbose is False - assert args.skip_label_check is False -def test_parse_args_all_flags() -> None: +def test_parse_args_all_flags(): args = parse_args([ "--repo", REPO, - "--state", "dismissed", - "--out", "out.json", "--issue-label", "custom-label", "--severity-priority-map", "Critical=Blocker", "--project-number", "42", "--project-org", "other-org", "--teams-webhook-url", "https://example.com/webhook", - "--skip-label-check", "--dry-run", "--verbose", ]) assert args.repo == REPO - assert args.state == "dismissed" - assert args.out_file == "out.json" assert args.issue_label == "custom-label" assert args.severity_priority_map == "Critical=Blocker" assert args.project_number == "42" assert args.project_org == "other-org" assert args.teams_webhook_url == "https://example.com/webhook" - assert args.skip_label_check is True assert args.dry_run is True assert args.verbose is True -def test_parse_args_state_choices() -> None: - for state in sorted(VALID_STATES): - args = parse_args(["--repo", REPO, "--state", state]) - assert args.state == state - - -def test_parse_args_invalid_state_rejected() -> None: - with pytest.raises(SystemExit): - parse_args(["--repo", REPO, "--state", "bogus"]) - - -def test_parse_args_env_fallbacks(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("SEVERITY_PRIORITY_MAP", "High=Urgent") - monkeypatch.setenv("PROJECT_NUMBER", "99") - monkeypatch.setenv("PROJECT_ORG", "env-org") - monkeypatch.setenv("TEAMS_WEBHOOK_URL", "https://env.example.com") - args = parse_args(["--repo", REPO]) - assert args.severity_priority_map == "High=Urgent" - assert args.project_number == "99" - assert args.project_org == "env-org" - assert args.teams_webhook_url == "https://env.example.com" - - -def test_parse_args_cli_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("PROJECT_NUMBER", "99") - args = parse_args(["--repo", REPO, "--project-number", "42"]) - assert args.project_number == "42" - - -def test_resolve_repo_cli_value() -> None: - assert _resolve_repo("my-org/my-repo") == "my-org/my-repo" - +# main - config validation -def test_resolve_repo_env_fallback(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("GITHUB_REPOSITORY", "env-org/env-repo") - assert _resolve_repo("") == "env-org/env-repo" - -def test_resolve_repo_empty_raises(monkeypatch: pytest.MonkeyPatch) -> None: +def test_no_repo_raises_system_exit(monkeypatch): monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) - with pytest.raises(SystemExit, match="repo not specified"): - _resolve_repo("") - - -def test_resolve_repo_no_slash_raises() -> None: - with pytest.raises(SystemExit, match="repo not specified"): - _resolve_repo("noslash") - - -def test_missing_labels_returns_1(mocker: MockerFixture) -> None: - mocker.patch("security.main.check_labels", return_value=["epic"]) - rc = main(["--repo", REPO]) - assert rc == 1 - - -def test_skip_label_check(mocker: MockerFixture, tmp_path) -> None: - mock_check = mocker.patch("security.main.check_labels") - mocker.patch("security.main.collect_alert_main") - mocker.patch("security.main.promote_alerts_main") - out = str(tmp_path / "alerts.json") - rc = main(["--repo", REPO, "--skip-label-check", "--out", out]) - mock_check.assert_not_called() - assert rc == 0 - - -def test_labels_ok_proceeds(mocker: MockerFixture, tmp_path) -> None: - mocker.patch("security.main.check_labels", return_value=[]) - mocker.patch("security.main.collect_alert_main") - mocker.patch("security.main.promote_alerts_main") - out = str(tmp_path / "alerts.json") - rc = main(["--repo", REPO, "--out", out]) - assert rc == 0 - - -def test_existing_file_is_silently_overwritten(mocker: MockerFixture, tmp_path) -> None: - mocker.patch("security.main.check_labels", return_value=[]) - mocker.patch("security.main.collect_alert_main") - mocker.patch("security.main.promote_alerts_main") - out = tmp_path / "alerts.json" - out.write_text("{}") - rc = main(["--repo", REPO, "--out", str(out)]) - assert rc == 0 - - -def test_nonexistent_file_proceeds(mocker: MockerFixture, tmp_path) -> None: - mocker.patch("security.main.check_labels", return_value=[]) - mocker.patch("security.main.collect_alert_main") - mocker.patch("security.main.promote_alerts_main") - out = str(tmp_path / "new.json") - rc = main(["--repo", REPO, "--out", out]) - assert rc == 0 - - -def test_collect_called_with_basic_args(mocker: MockerFixture, tmp_path) -> None: - mocker.patch("security.main.check_labels", return_value=[]) - mock_collect = mocker.patch("security.main.collect_alert_main") - mocker.patch("security.main.promote_alerts_main") - out = str(tmp_path / "alerts.json") - main(["--repo", REPO, "--state", "fixed", "--out", out]) - call_args = mock_collect.call_args[0][0] - assert "--repo" in call_args - assert REPO in call_args - assert "--state" in call_args - assert "fixed" in call_args - assert "--out" in call_args - assert out in call_args - - -def test_verbose_forwarded_to_collect(mocker: MockerFixture, tmp_path) -> None: - mocker.patch("security.main.check_labels", return_value=[]) - mock_collect = mocker.patch("security.main.collect_alert_main") - mocker.patch("security.main.promote_alerts_main") - out = str(tmp_path / "alerts.json") - main(["--repo", REPO, "--verbose", "--out", out]) - call_args = mock_collect.call_args[0][0] - assert "--verbose" in call_args - - -def test_promote_basic_args(mocker: MockerFixture, tmp_path) -> None: - call_args = _run_promote(mocker, tmp_path) - assert "--file" in call_args - assert "--issue-label" in call_args - assert "scope:security" in call_args + with pytest.raises(SystemExit): + main([]) -def test_promote_dry_run_forwarded(mocker: MockerFixture, tmp_path) -> None: - call_args = _run_promote(mocker, tmp_path, ["--dry-run"]) - assert "--dry-run" in call_args +def test_env_repo_fallback(mocker, monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", REPO) + mocks = _mock_pipeline(mocker) + assert main([]) == 0 + mocks["auth"].assert_called_once() -def test_promote_verbose_forwarded(mocker: MockerFixture, tmp_path) -> None: - call_args = _run_promote(mocker, tmp_path, ["--verbose"]) - assert "--verbose" in call_args +# main - label check -def test_promote_teams_webhook_forwarded(mocker: MockerFixture, tmp_path) -> None: - call_args = _run_promote(mocker, tmp_path, ["--teams-webhook-url", "https://x.com/wh"]) - assert "--teams-webhook-url" in call_args - assert "https://x.com/wh" in call_args +def test_missing_labels_returns_1(mocker): + mocker.patch.object(LabelChecker, "check_labels", return_value=["epic"]) + assert main(["--repo", REPO]) == 1 -def test_promote_severity_priority_map_forwarded(mocker: MockerFixture, tmp_path) -> None: - call_args = _run_promote(mocker, tmp_path, ["--severity-priority-map", "Critical=Blocker"]) - assert "--severity-priority-map" in call_args - assert "Critical=Blocker" in call_args +# main - pipeline success -def test_promote_project_number_forwarded(mocker: MockerFixture, tmp_path) -> None: - call_args = _run_promote(mocker, tmp_path, ["--project-number", "42"]) - assert "--project-number" in call_args - assert "42" in call_args +def test_pipeline_success_returns_0(mocker): + _mock_pipeline(mocker) + assert main(["--repo", REPO]) == 0 -def test_promote_project_org_forwarded(mocker: MockerFixture, tmp_path) -> None: - call_args = _run_promote(mocker, tmp_path, ["--project-org", "other-org"]) - assert "--project-org" in call_args - assert "other-org" in call_args +def test_pipeline_calls_notify(mocker): + mocks = _mock_pipeline(mocker) + main(["--repo", REPO]) + mocks["notifier"].assert_called_once() -def test_promote_issue_label_forwarded(mocker: MockerFixture, tmp_path) -> None: - call_args = _run_promote(mocker, tmp_path, ["--issue-label", "custom"]) - assert "--issue-label" in call_args - assert "custom" in call_args +def test_pipeline_call_order(mocker): + call_order: list[str] = [] + mocker.patch.object(LabelChecker, "check_labels", side_effect=lambda: (call_order.append("check"), [])[-1]) + mock_auth = mocker.patch("security.main.AquaSecAuthenticator") + mock_auth.return_value.authenticate.side_effect = lambda: (call_order.append("auth"), "token")[-1] + mock_fetcher = mocker.patch("security.main.ScanFetcher") + mock_fetcher.return_value.fetch_findings.side_effect = lambda: (call_order.append("fetch"), {"total": 0, "data": []})[-1] + mock_parser = mocker.patch("security.main.AquaSecParser") + mock_parser.return_value.parse.side_effect = lambda *a: (call_order.append("parse"), mocker.Mock(open_by_number={}))[-1] + mock_syncer = mocker.patch("security.main.IssueSyncer") + mock_syncer.return_value.sync.side_effect = lambda *a, **kw: (call_order.append("sync"), mocker.Mock())[-1] + mocker.patch.object(NotificationSender, "notify", side_effect=lambda *a, **kw: call_order.append("notify")) + main(["--repo", REPO]) -def test_promote_empty_optionals_not_forwarded(mocker: MockerFixture, tmp_path) -> None: - call_args = _run_promote(mocker, tmp_path) - assert "--teams-webhook-url" not in call_args - assert "--severity-priority-map" not in call_args - assert "--project-number" not in call_args - assert "--project-org" not in call_args + assert call_order == ["check", "auth", "fetch", "parse", "sync", "notify"] -def test_pipeline_success_returns_0(mocker: MockerFixture, tmp_path) -> None: - mocker.patch("security.main.check_labels", return_value=[]) - mocker.patch("security.main.collect_alert_main") - mocker.patch("security.main.promote_alerts_main") - out = str(tmp_path / "alerts.json") - assert main(["--repo", REPO, "--out", out]) == 0 +# main - error propagation -def test_collect_error_propagates(mocker: MockerFixture, tmp_path) -> None: - mocker.patch("security.main.check_labels", return_value=[]) - mocker.patch("security.main.collect_alert_main", side_effect=SystemExit(1)) - out = str(tmp_path / "alerts.json") - with pytest.raises(SystemExit): - main(["--repo", REPO, "--out", out]) +def test_auth_error_propagates(mocker): + mocker.patch.object(LabelChecker, "check_labels", return_value=[]) + mock_auth = mocker.patch("security.main.AquaSecAuthenticator") + mock_auth.return_value.authenticate.side_effect = SystemExit("auth failed") + with pytest.raises(SystemExit, match="auth failed"): + main(["--repo", REPO]) -def test_promote_error_propagates(mocker: MockerFixture, tmp_path) -> None: - mocker.patch("security.main.check_labels", return_value=[]) - mocker.patch("security.main.collect_alert_main") - mocker.patch("security.main.promote_alerts_main", side_effect=SystemExit(1)) - out = str(tmp_path / "alerts.json") - with pytest.raises(SystemExit): - main(["--repo", REPO, "--out", out]) +def test_fetch_error_propagates(mocker): + mocker.patch.object(LabelChecker, "check_labels", return_value=[]) + mock_auth = mocker.patch("security.main.AquaSecAuthenticator") + mock_auth.return_value.authenticate.return_value = "token" + mock_fetcher = mocker.patch("security.main.ScanFetcher") + mock_fetcher.return_value.fetch_findings.side_effect = SystemExit("fetch failed") -def test_pipeline_call_order(mocker: MockerFixture, tmp_path) -> None: - call_order: list[str] = [] - mocker.patch( - "security.main.check_labels", - return_value=[], - side_effect=lambda *a, **k: (call_order.append("check"), [])[-1], - ) - mocker.patch( - "security.main.collect_alert_main", - side_effect=lambda *a, **k: call_order.append("collect"), - ) - mocker.patch( - "security.main.promote_alerts_main", - side_effect=lambda *a, **k: call_order.append("promote"), - ) - out = str(tmp_path / "alerts.json") - main(["--repo", REPO, "--out", out]) - assert call_order == ["check", "collect", "promote"] - - -def test_env_repo_fallback(mocker: MockerFixture, tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("GITHUB_REPOSITORY", REPO) - mocker.patch("security.main.check_labels", return_value=[]) - mock_collect = mocker.patch("security.main.collect_alert_main") - mocker.patch("security.main.promote_alerts_main") - out = str(tmp_path / "alerts.json") - assert main(["--out", out]) == 0 - call_args = mock_collect.call_args[0][0] - assert REPO in call_args + with pytest.raises(SystemExit, match="fetch failed"): + main(["--repo", REPO]) -def test_no_repo_returns_error(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) - with pytest.raises(SystemExit, match="repo not specified"): - main([]) +# main - verbose via RUNNER_DEBUG -def test_verbose_via_runner_debug(mocker: MockerFixture, tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_verbose_via_runner_debug(mocker, monkeypatch): monkeypatch.setenv("RUNNER_DEBUG", "1") - mocker.patch("security.main.check_labels", return_value=[]) - mock_collect = mocker.patch("security.main.collect_alert_main") - mocker.patch("security.main.promote_alerts_main") - out = str(tmp_path / "alerts.json") - main(["--repo", REPO, "--out", out]) - call_args = mock_collect.call_args[0][0] - assert "--verbose" in call_args + _mock_pipeline(mocker) + assert main(["--repo", REPO]) == 0 diff --git a/tests/security/test_promote_alerts.py b/tests/security/test_promote_alerts.py deleted file mode 100644 index 591d027..0000000 --- a/tests/security/test_promote_alerts.py +++ /dev/null @@ -1,251 +0,0 @@ -# -# 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. -# - -"""Unit tests for ``promote_alerts.py`` – CLI entry-point wiring.""" - -from types import SimpleNamespace - -import pytest -from pytest_mock import MockerFixture - -from security.alerts.models import LoadedAlerts -from security.issues.models import NotifiedIssue, SeverityChange, SyncResult - -# Default empty sync result reused across tests. -_SYNC_RESULT_EMPTY = SyncResult(notifications=[], severity_changes=[]) - - -# ===================================================================== -# parse_args -# ===================================================================== - - -def test_parse_args_defaults(monkeypatch: pytest.MonkeyPatch) -> None: - """Defaults are applied when no CLI args are given.""" - monkeypatch.setattr("sys.argv", ["security.promote_alerts.py"]) - monkeypatch.delenv("SEVERITY_PRIORITY_MAP", raising=False) - monkeypatch.delenv("PROJECT_NUMBER", raising=False) - monkeypatch.delenv("PROJECT_ORG", raising=False) - monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False) - from security.promote_alerts import parse_args - - args = parse_args() - assert args.file == "alerts.json" - assert args.dry_run is False - assert args.verbose is False - - -def test_parse_args_all_flags(monkeypatch: pytest.MonkeyPatch) -> None: - """All CLI flags and options are parsed correctly.""" - monkeypatch.setattr("sys.argv", [ - "security.promote_alerts.py", - "--file", "custom.json", - "--dry-run", - "--verbose", - "--issue-label", "my-label", - "--severity-priority-map", "High=Urgent", - "--project-number", "42", - "--project-org", "my-org", - "--teams-webhook-url", "https://hook.example.com", - ]) - from security.promote_alerts import parse_args - - args = parse_args() - assert args.file == "custom.json" - assert args.dry_run is True - assert args.verbose is True - assert args.issue_label == "my-label" - assert args.severity_priority_map == "High=Urgent" - assert args.project_number == 42 - assert args.project_org == "my-org" - assert args.teams_webhook_url == "https://hook.example.com" - - -# ===================================================================== -# main() – gh CLI guard -# ===================================================================== - - -def test_missing_gh_cli_raises(monkeypatch: pytest.MonkeyPatch) -> None: - """main() raises SystemExit when gh CLI is not found.""" - monkeypatch.setattr("sys.argv", ["security.promote_alerts.py"]) - monkeypatch.setattr("shutil.which", lambda _cmd: None) - from security.promote_alerts import main - - with pytest.raises(SystemExit, match="gh CLI"): - main() - - -# ===================================================================== -# Fixture: mock all external deps used by main() -# ===================================================================== - - -@pytest.fixture() -def main_mocks(mocker: MockerFixture) -> SimpleNamespace: - """Provide mocked dependencies for ``main()`` with sensible defaults.""" - return SimpleNamespace( - which=mocker.patch("security.promote_alerts.shutil.which", return_value="/usr/bin/gh"), - load=mocker.patch( - "security.promote_alerts.load_open_alerts_from_file", - return_value=LoadedAlerts(repo_full="org/repo", open_by_number={}), - ), - list_issues=mocker.patch( - "security.promote_alerts.gh_issue_list_by_label", - return_value={}, - ), - sync=mocker.patch( - "security.promote_alerts.sync_alerts_and_issues", - return_value=_SYNC_RESULT_EMPTY, - ), - notify=mocker.patch("security.promote_alerts.notify_teams"), - notify_sev=mocker.patch("security.promote_alerts.notify_teams_severity_changes"), - ) - - -# ===================================================================== -# main() – wiring tests -# ===================================================================== - - -def test_main_dry_run( - main_mocks: SimpleNamespace, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Dry-run mode passes dry_run=True to sync_alerts_and_issues.""" - monkeypatch.setattr("sys.argv", ["security.promote_alerts.py", "--dry-run"]) - monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False) - monkeypatch.delenv("SEVERITY_PRIORITY_MAP", raising=False) - monkeypatch.delenv("PROJECT_NUMBER", raising=False) - monkeypatch.delenv("PROJECT_ORG", raising=False) - from security.promote_alerts import main - - main() - _, kwargs = main_mocks.sync.call_args - assert kwargs["dry_run"] is True - - -def test_main_passes_file_arg( - main_mocks: SimpleNamespace, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """--file value is forwarded to load_open_alerts_from_file.""" - monkeypatch.setattr("sys.argv", ["security.promote_alerts.py", "--file", "custom.json"]) - monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False) - monkeypatch.delenv("SEVERITY_PRIORITY_MAP", raising=False) - monkeypatch.delenv("PROJECT_NUMBER", raising=False) - monkeypatch.delenv("PROJECT_ORG", raising=False) - from security.promote_alerts import main - - main() - main_mocks.load.assert_called_once_with("custom.json") - - -def test_main_no_webhook_skips_notification( - main_mocks: SimpleNamespace, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Without TEAMS_WEBHOOK_URL, notify_teams is still called (with empty url).""" - monkeypatch.setattr("sys.argv", ["security.promote_alerts.py"]) - monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False) - monkeypatch.delenv("SEVERITY_PRIORITY_MAP", raising=False) - monkeypatch.delenv("PROJECT_NUMBER", raising=False) - monkeypatch.delenv("PROJECT_ORG", raising=False) - - # sync returns notifications to trigger the notification branch - main_mocks.sync.return_value = SyncResult( - notifications=[ - NotifiedIssue( - repo="org/repo", issue_number=1, severity="high", - category="sast", state="new", tool="AquaSec", - ), - ], - severity_changes=[], - ) - from security.promote_alerts import main - - main() - # Without webhook URL, logging.debug is hit and notify_teams is not called - main_mocks.notify.assert_not_called() - - -def test_main_with_webhook_sends_notifications( - main_mocks: SimpleNamespace, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """When TEAMS_WEBHOOK_URL is set and there are notifications, notify_teams is called.""" - monkeypatch.setattr("sys.argv", [ - "security.promote_alerts.py", "--teams-webhook-url", "https://hook.example.com", - ]) - monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False) - monkeypatch.delenv("SEVERITY_PRIORITY_MAP", raising=False) - monkeypatch.delenv("PROJECT_NUMBER", raising=False) - monkeypatch.delenv("PROJECT_ORG", raising=False) - - main_mocks.sync.return_value = SyncResult( - notifications=[ - NotifiedIssue( - repo="org/repo", issue_number=1, severity="high", - category="sast", state="new", tool="AquaSec", - ), - ], - severity_changes=[], - ) - from security.promote_alerts import main - - main() - main_mocks.notify.assert_called_once() - call_args = main_mocks.notify.call_args - assert call_args[0][0] == "https://hook.example.com" - - -def test_main_severity_priority_map_forwarded( - main_mocks: SimpleNamespace, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """--severity-priority-map value is parsed and forwarded to sync.""" - monkeypatch.setattr("sys.argv", [ - "security.promote_alerts.py", "--severity-priority-map", "High=Urgent,Low=Minor", - ]) - monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False) - monkeypatch.delenv("SEVERITY_PRIORITY_MAP", raising=False) - monkeypatch.delenv("PROJECT_NUMBER", raising=False) - monkeypatch.delenv("PROJECT_ORG", raising=False) - from security.promote_alerts import main - - main() - _, kwargs = main_mocks.sync.call_args - assert kwargs["severity_priority_map"] == {"high": "Urgent", "low": "Minor"} - - -def test_main_project_number_forwarded( - main_mocks: SimpleNamespace, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """--project-number and --project-org are forwarded to sync.""" - monkeypatch.setattr("sys.argv", [ - "security.promote_alerts.py", "--project-number", "42", "--project-org", "my-org", - ]) - monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False) - monkeypatch.delenv("SEVERITY_PRIORITY_MAP", raising=False) - monkeypatch.delenv("PROJECT_NUMBER", raising=False) - monkeypatch.delenv("PROJECT_ORG", raising=False) - from security.promote_alerts import main - - main() - _, kwargs = main_mocks.sync.call_args - assert kwargs["project_number"] == 42 - assert kwargs["project_org"] == "my-org" diff --git a/tests/security/test_send_notifications.py b/tests/security/test_send_notifications.py deleted file mode 100644 index db77005..0000000 --- a/tests/security/test_send_notifications.py +++ /dev/null @@ -1,219 +0,0 @@ -# -# 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. -# - -"""Unit tests for ``send_to_teams`` – Adaptive Card builder & CLI helpers.""" - - -import json -import logging -import types -from pathlib import Path - -import pytest - -from security.send_notifications import ( - _build_card_body, - _parse_args, - _resolve_body, - _text_block, - build_payload, - main, - send_to_teams, -) - - -# ===================================================================== -# _text_block -# ===================================================================== - - -def test_defaults() -> None: - block = _text_block("hello") - assert block["type"] == "TextBlock" - assert block["text"] == "hello" - assert block["wrap"] is True - -def test_extra_kwargs() -> None: - block = _text_block("hello", weight="Bolder", size="Large") - assert block["weight"] == "Bolder" - assert block["size"] == "Large" - - -# ===================================================================== -# _build_card_body -# ===================================================================== - - -def test_body_only() -> None: - elements = _build_card_body("some body") - assert len(elements) == 1 - assert elements[0]["type"] == "Container" - assert elements[0]["items"][0]["text"] == "some body" - -def test_with_title() -> None: - elements = _build_card_body("body", title="Title") - assert len(elements) == 2 - header = elements[0] - assert header["type"] == "Container" - assert header["style"] == "accent" - assert header["items"][0]["text"] == "Title" - -def test_with_title_and_subtitle() -> None: - elements = _build_card_body("body", title="T", subtitle="S") - header = elements[0] - assert len(header["items"]) == 2 - assert header["items"][1]["text"] == "S" - - -# ===================================================================== -# build_payload -# ===================================================================== - - -def test_structure() -> None: - payload = build_payload("msg") - assert payload["type"] == "message" - attachment = payload["attachments"][0] - assert attachment["contentType"] == "application/vnd.microsoft.card.adaptive" - card = attachment["content"] - assert card["type"] == "AdaptiveCard" - assert card["version"] == "1.5" - assert isinstance(card["body"], list) - -def test_body_text_in_card() -> None: - payload = build_payload("Hello **world**") - card_body = payload["attachments"][0]["content"]["body"] - texts = [item["items"][0]["text"] for item in card_body if "items" in item] - assert any("Hello **world**" in t for t in texts) - -def test_serialisable() -> None: - payload = build_payload("x", title="T", subtitle="S") - # Must be JSON serialisable without error - json.dumps(payload) - - -# ===================================================================== -# _parse_args -# ===================================================================== - - -def test_body_arg() -> None: - args = _parse_args(["--body", "hello"]) - assert args.body == "hello" - assert args.body_file is None - -def test_body_file_arg() -> None: - args = _parse_args(["--body-file", "/tmp/f.md"]) - assert args.body_file == "/tmp/f.md" - assert args.body is None - -def test_dry_run() -> None: - args = _parse_args(["--body", "x", "--dry-run"]) - assert args.dry_run is True - -def test_title_and_subtitle() -> None: - args = _parse_args(["--body", "x", "--title", "T", "--subtitle", "S"]) - assert args.title == "T" - assert args.subtitle == "S" - - -# ===================================================================== -# _resolve_body -# ===================================================================== - - -def test_from_body_arg() -> None: - args = _parse_args(["--body", "inline text"]) - assert _resolve_body(args) == "inline text" - -def test_from_file(tmp_path: Path) -> None: - f = tmp_path / "msg.md" - f.write_text("file content", encoding="utf-8") - args = _parse_args(["--body-file", str(f)]) - assert _resolve_body(args) == "file content" - -def test_no_body_raises(monkeypatch: pytest.MonkeyPatch) -> None: - args = _parse_args([]) - # stdin is a tty in tests, so it should raise - fake_stdin = types.SimpleNamespace(isatty=lambda: True) - monkeypatch.setattr("security.send_notifications.sys.stdin", fake_stdin) - with pytest.raises(SystemExit): - _resolve_body(args) - -def test_from_stdin(monkeypatch: pytest.MonkeyPatch) -> None: - """Body is read from stdin when neither --body nor --body-file is given.""" - args = _parse_args([]) - fake_stdin = types.SimpleNamespace(isatty=lambda: False, read=lambda: "piped content") - monkeypatch.setattr("security.send_notifications.sys.stdin", fake_stdin) - assert _resolve_body(args) == "piped content" - - -# ===================================================================== -# main -# ===================================================================== - - -def test_dry_run_prints_json(caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.INFO): - main(["--body", "hi", "--dry-run"]) - json_text = next(r.message for r in caplog.records if r.message.strip().startswith("{")) - payload = json.loads(json_text) - assert payload["type"] == "message" - -def test_empty_body_raises(tmp_path: Path) -> None: - f = tmp_path / "empty.md" - f.write_text(" ", encoding="utf-8") - with pytest.raises(SystemExit, match="empty"): - main(["--body-file", str(f)]) - -def test_no_webhook_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False) - with pytest.raises(SystemExit, match="webhook"): - main(["--body", "x"]) - -def test_main_sends_when_not_dry_run(monkeypatch: pytest.MonkeyPatch) -> None: - """Non-dry-run path: main() calls send_to_teams with the webhook URL.""" - calls: list[tuple] = [] - monkeypatch.setattr("security.send_notifications.send_to_teams", lambda url, payload: calls.append((url, payload))) - main(["--body", "hi", "--webhook-url", "https://hook"]) - assert len(calls) == 1 - assert calls[0][0] == "https://hook" - assert calls[0][1]["type"] == "message" - - -# ===================================================================== -# send_to_teams (HTTP mocked) -# ===================================================================== - - -def test_success(monkeypatch: pytest.MonkeyPatch) -> None: - calls: list[tuple] = [] - - def fake_post(url, **kwargs): - calls.append((url, kwargs)) - return types.SimpleNamespace(status_code=200, text="1") - - monkeypatch.setattr("security.send_notifications.requests.post", fake_post) - send_to_teams("https://hook", {"type": "message"}) - assert len(calls) == 1 - -def test_failure_raises(monkeypatch: pytest.MonkeyPatch) -> None: - def fake_post(url, **kwargs): - return types.SimpleNamespace(status_code=500, text="error") - - monkeypatch.setattr("security.send_notifications.requests.post", fake_post) - with pytest.raises(SystemExit, match="failed"): - send_to_teams("https://hook", {"type": "message"}) From 9026ff740bce6bc14c6fec37846628f807f0adf6 Mon Sep 17 00:00:00 2001 From: "Tobias.Mikula" Date: Sun, 31 May 2026 15:46:21 +0200 Subject: [PATCH 2/5] CodeRabbit comments implementation. --- docs/security/security.md | 1 - src/security/alerts/aquasec_parser.py | 3 ++- src/security/config.py | 2 +- src/security/constants.py | 2 +- src/security/main.py | 2 +- src/security/services/__init__.py | 2 +- src/security/services/label_checker.py | 4 ++-- tests/security/alerts/test_aquasec_parser.py | 12 ++++++------ tests/security/services/__init__.py | 1 - tests/security/services/test_notification_sender.py | 3 +-- tests/security/test_main.py | 1 + 11 files changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/security/security.md b/docs/security/security.md index 98feb86..2725212 100644 --- a/docs/security/security.md +++ b/docs/security/security.md @@ -71,7 +71,6 @@ fingerprint=a1b2c3d4e5f6789012345678abcdef01 repo=my-org/my-service rule_id=CVE-2024-99999 severity=critical -gh_alert_numbers=["7"] --> ## General Information diff --git a/src/security/alerts/aquasec_parser.py b/src/security/alerts/aquasec_parser.py index 0841343..2ed6ca2 100644 --- a/src/security/alerts/aquasec_parser.py +++ b/src/security/alerts/aquasec_parser.py @@ -41,7 +41,8 @@ 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_list = extra.get("references") or [] + references_raw = extra.get("references") + references_list = references_raw if isinstance(references_raw, list) else [] metadata = AlertMetadata( alert_number=0, diff --git a/src/security/config.py b/src/security/config.py index 0c32896..b09372f 100644 --- a/src/security/config.py +++ b/src/security/config.py @@ -56,7 +56,7 @@ def load(cls, args: argparse.Namespace) -> "SecurityConfig": if project_number_raw: try: project_number = int(project_number_raw) - except ValueError, TypeError: + except (ValueError, TypeError): project_number = None return cls( diff --git a/src/security/constants.py b/src/security/constants.py index 3e1b161..2fda893 100644 --- a/src/security/constants.py +++ b/src/security/constants.py @@ -49,4 +49,4 @@ FETCH_SLEEP_SECONDS = 2 # Severity mapping (AquaSec numeric → lowercase string) -SEVERITY_MAP: dict[int, str] = {1: "critical", 2: "high", 3: "medium", 4: "low"} +SEVERITY_MAP: dict[int, str] = {1: "low", 2: "medium", 3: "high", 4: "critical"} diff --git a/src/security/main.py b/src/security/main.py index 3be8ee7..6bf7806 100644 --- a/src/security/main.py +++ b/src/security/main.py @@ -117,7 +117,7 @@ def main(argv: list[str] | None = None) -> int: # Check required labels if missing := LabelChecker(repo).check_labels(): - logger.error("Required labels missing in %s: %s", repo, ", ".join(missing)) + logger.error("%sRequired labels missing in %s: %s", LOGGING_PREFIX, repo, ", ".join(missing)) return 1 logger.info("%sAll required labels present", LOGGING_PREFIX) diff --git a/src/security/services/__init__.py b/src/security/services/__init__.py index c37ed74..8715e44 100644 --- a/src/security/services/__init__.py +++ b/src/security/services/__init__.py @@ -14,4 +14,4 @@ # limitations under the License. # -"""Security services package – AquaSec API integration.""" +"""Security services package - AquaSec API integration.""" diff --git a/src/security/services/label_checker.py b/src/security/services/label_checker.py index 1355bd8..b7f7015 100644 --- a/src/security/services/label_checker.py +++ b/src/security/services/label_checker.py @@ -20,7 +20,7 @@ import logging from core.github.client import run_gh -from security.constants import REQUIRED_LABELS +from security.constants import LOGGING_PREFIX, REQUIRED_LABELS logger = logging.getLogger(__name__) @@ -52,7 +52,7 @@ def _fetch_labels(self) -> list[str]: """ result = run_gh(["label", "list", "--repo", self.repo, "--json", "name", "--limit", "500"]) if result.returncode != 0: - logger.error("gh label list failed for %s:\n%s", self.repo, result.stderr) + logger.error("%sgh label list failed for %s:\n%s", LOGGING_PREFIX, self.repo, result.stderr) raise SystemExit(1) labels = json.loads(result.stdout) return [entry["name"] for entry in labels if entry.get("name")] diff --git a/tests/security/alerts/test_aquasec_parser.py b/tests/security/alerts/test_aquasec_parser.py index d91a37d..ed6cbf6 100644 --- a/tests/security/alerts/test_aquasec_parser.py +++ b/tests/security/alerts/test_aquasec_parser.py @@ -129,10 +129,10 @@ # _map_severity @pytest.mark.parametrize("numeric,expected", [ - (1, "critical"), - (2, "high"), - (3, "medium"), - (4, "low"), + (1, "low"), + (2, "medium"), + (3, "high"), + (4, "critical"), (99, "unknown"), (0, "unknown"), ]) @@ -163,7 +163,7 @@ def test_parse_vulnerability_item() -> None: assert alert.metadata.rule_id == "CVE-2026-33870" assert alert.metadata.rule_name == "vulnerabilities" assert alert.metadata.rule_description == "netty-codec-http: Request smuggling via chunked transfer" - assert alert.metadata.severity == "high" + assert alert.metadata.severity == "medium" assert alert.metadata.file == "shared-http-client/pom.xml" assert alert.metadata.start_line is None # 0 maps to None assert alert.metadata.tool == "AquaSec" @@ -190,7 +190,7 @@ def test_parse_sast_item() -> None: alert = _parse_item(_SAST_ITEM, "target-org/target-repo") assert alert.metadata.rule_id == "insecure-disable-cert-verification-aquasec-python" - assert alert.metadata.severity == "medium" + assert alert.metadata.severity == "high" assert alert.metadata.start_line == 29 assert alert.metadata.end_line == 29 diff --git a/tests/security/services/__init__.py b/tests/security/services/__init__.py index 6d963e6..cc5255a 100644 --- a/tests/security/services/__init__.py +++ b/tests/security/services/__init__.py @@ -14,4 +14,3 @@ # limitations under the License. # -"""Tests for security services package.""" diff --git a/tests/security/services/test_notification_sender.py b/tests/security/services/test_notification_sender.py index d6a48b4..6a4f06d 100644 --- a/tests/security/services/test_notification_sender.py +++ b/tests/security/services/test_notification_sender.py @@ -17,6 +17,7 @@ """Tests for security.services.notification_sender module.""" import pytest +import requests from unittest.mock import MagicMock from security.issues.models import NotifiedIssue, SeverityChange @@ -179,8 +180,6 @@ def test_send_raises_system_exit_on_non_200(mocker): def test_send_raises_system_exit_on_request_exception(mocker): - import requests - mocker.patch( "security.services.notification_sender.requests.post", side_effect=requests.RequestException("Connection failed"), diff --git a/tests/security/test_main.py b/tests/security/test_main.py index 95d8bbe..a83cc2e 100644 --- a/tests/security/test_main.py +++ b/tests/security/test_main.py @@ -34,6 +34,7 @@ def _aqua_env(monkeypatch): monkeypatch.setenv("AQUA_SECRET", "test-secret") monkeypatch.setenv("AQUA_GROUP_ID", "12345") monkeypatch.setenv("AQUA_REPOSITORY_ID", "abc12345-e89b-12d3-a456-426614174000") + monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/gh") def _mock_pipeline(mocker: MockerFixture): From b61d9ea5649ef0a90d25a7a8d2b7a8a967d4836c Mon Sep 17 00:00:00 2001 From: "Tobias.Mikula" Date: Sun, 31 May 2026 16:20:40 +0200 Subject: [PATCH 3/5] Improving the notification sender logging. --- src/security/config.py | 2 +- src/security/services/notification_sender.py | 2 +- tests/security/services/test_notification_sender.py | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/security/config.py b/src/security/config.py index b09372f..0c32896 100644 --- a/src/security/config.py +++ b/src/security/config.py @@ -56,7 +56,7 @@ def load(cls, args: argparse.Namespace) -> "SecurityConfig": if project_number_raw: try: project_number = int(project_number_raw) - except (ValueError, TypeError): + except ValueError, TypeError: project_number = None return cls( diff --git a/src/security/services/notification_sender.py b/src/security/services/notification_sender.py index 7c1e986..1d6079e 100644 --- a/src/security/services/notification_sender.py +++ b/src/security/services/notification_sender.py @@ -179,7 +179,7 @@ def send(self, body: str, *, title: str | None = None, subtitle: str | None = No except requests.RequestException as e: raise SystemExit(f"ERROR: Teams webhook request failed: {e}") from e - if resp.status_code != 200 or resp.text.strip() not in ("1", ""): + if not resp.ok: raise SystemExit(f"ERROR: Teams webhook request failed.\n Status: {resp.status_code}\n Body: {resp.text}") logger.info("%sMessage sent to Teams successfully.", LOGGING_PREFIX) diff --git a/tests/security/services/test_notification_sender.py b/tests/security/services/test_notification_sender.py index 6a4f06d..100ad03 100644 --- a/tests/security/services/test_notification_sender.py +++ b/tests/security/services/test_notification_sender.py @@ -160,6 +160,7 @@ def test_notify_dry_run_passed_through(mocker, sample_notifications): def test_send_posts_to_webhook(mocker): mock_response = mocker.Mock() mock_response.status_code = 200 + mock_response.ok = True mock_response.text = "1" mock_post = mocker.patch("security.services.notification_sender.requests.post", return_value=mock_response) @@ -172,6 +173,7 @@ def test_send_posts_to_webhook(mocker): def test_send_raises_system_exit_on_non_200(mocker): mock_response = mocker.Mock() mock_response.status_code = 500 + mock_response.ok = False mock_response.text = "Internal error" mocker.patch("security.services.notification_sender.requests.post", return_value=mock_response) From 9bf6e27571bd420f154edf5a93361c9060ee986a Mon Sep 17 00:00:00 2001 From: "Tobias.Mikula" Date: Sun, 31 May 2026 16:38:55 +0200 Subject: [PATCH 4/5] config.py bug fix finally --- src/security/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/security/config.py b/src/security/config.py index 0c32896..b09372f 100644 --- a/src/security/config.py +++ b/src/security/config.py @@ -56,7 +56,7 @@ def load(cls, args: argparse.Namespace) -> "SecurityConfig": if project_number_raw: try: project_number = int(project_number_raw) - except ValueError, TypeError: + except (ValueError, TypeError): project_number = None return cls( From 7193167c582bf93783ff0d129c0e63b7349aaed2 Mon Sep 17 00:00:00 2001 From: "Tobias.Mikula" Date: Sun, 31 May 2026 16:49:34 +0200 Subject: [PATCH 5/5] config.py bug fix finally --- src/security/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/security/config.py b/src/security/config.py index b09372f..a8ab753 100644 --- a/src/security/config.py +++ b/src/security/config.py @@ -56,7 +56,7 @@ def load(cls, args: argparse.Namespace) -> "SecurityConfig": if project_number_raw: try: project_number = int(project_number_raw) - except (ValueError, TypeError): + except (ValueError, TypeError): # fmt: skip project_number = None return cls(