Skip to content
7 changes: 7 additions & 0 deletions .github/workflows/aquasec-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,5 +129,12 @@ jobs:
MIN_SEVERITY: ${{ inputs.min-severity }}
run: |
python3 org-workflows/src/security/main.py \
--scan-output aquasec_scan.json \
${{ inputs.dry-run && '--dry-run' || '' }} \
${{ inputs.verbose-logging && '--verbose' || '' }}

- name: Upload scan findings artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: aquasec-night-scan
path: aquasec_scan.json
Comment on lines +136 to +140

@coderabbitai coderabbitai Bot Jul 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow excerpt =="
if [ -f .github/workflows/aquasec-scan.yml ]; then
  nl -ba .github/workflows/aquasec-scan.yml | sed -n '1,220p'
else
  echo "missing .github/workflows/aquasec-scan.yml"
fi

echo
echo "== docs excerpt =="
if [ -f docs/security/security.md ]; then
  nl -ba docs/security/security.md | sed -n '35,60p'
else
  echo "missing docs/security/security.md"
fi

echo
echo "== search relevant actions =="
rg -n "upload-artifact|aquasec_scan|scan Artifact|aquasec-night-scan|if-no-files-found|cancelled\\(\\)" .github docs || true

echo
echo "== git status/diff stat =="
git status --short
git diff --stat HEAD~1..HEAD 2>/dev/null || git diff --stat 2>/dev/null || true

Repository: AbsaOSS/organizational-workflows

Length of output: 233


🏁 Script executed:

#!/bin/sh
set -euo pipefail

echo "== workflow file size and excerpt =="
if [ -f .github/workflows/aquasec-scan.yml ]; then
  wc -l .github/workflows/aquasec-scan.yml
  awk 'NR>=1 && NR<=220 {printf "%6d\t%s\n", NR, $0}' .github/workflows/aquasec-scan.yml
else
  echo "missing .github/workflows/aquasec-scan.yml"
fi

echo
echo "== docs excerpt =="
if [ -f docs/security/security.md ]; then
  wc -l docs/security/security.md
  awk 'NR>=35 && NR<=60 {printf "%6d\t%s\n", NR, $0}' docs/security/security.md
else
  echo "missing docs/security/security.md"
fi

echo
echo "== search relevant actions =="
rg -n "upload-artifact|aquasec_scan|Scan Artifact|aquasec-night-scan|if-no-files-found|cancelled\\(\\)" .github docs || true

echo
echo "== git status/diff stat =="
git status --short || true
git diff --stat HEAD~1..HEAD 2>/dev/null || git diff --stat 2>/dev/null || true

Repository: AbsaOSS/organizational-workflows

Length of output: 10225


🌐 Web query:

actions/upload-artifact v4 behavior when workflow job fails does it upload artifact if files already exist skipped unless continue-on-error

💡 Result:

In GitHub Actions, the actions/upload-artifact@v4 action does not automatically upload files if a job fails; standard steps in a workflow job are skipped when a previous step fails or the job itself enters a failed state [1][2][3]. To ensure an artifact is uploaded even when a job fails, you must explicitly use the if: failure or if: always conditional on the upload step [1][3]. Regarding the behavior of v4: - Artifacts are immutable once created [4][5]. - Uploading to an artifact name that already exists in the same workflow run will cause an error (Conflict: 409) [4][6]. - If you use the overwrite: true option, the action will attempt to delete the existing artifact with that name before creating the new one [7][8]. However, this is not atomic and is intended for serial operations (e.g., modifying a file and re-uploading), not for parallel jobs trying to merge data into a single artifact [9]. - If a job fails, and you have not configured the upload step with an appropriate conditional like if: failure, that step will be skipped and no artifact will be uploaded from that job [1][2]. In summary, the action does not have a built-in mechanism to "catch" and upload artifacts upon failure unless you explicitly configure the step to run under those conditions using GitHub Actions workflow syntax [1][3].

Citations:


🌐 Web query:

actions/upload-artifact if-no-files-found ignore continue run on failed job

💡 Result:

The if-no-files-found input for the actions/upload-artifact GitHub Action is used to control the behavior of the step when no files are matched by the specified path. It accepts three values [1][2]: - warn (default): Prints a warning to the logs but the action completes successfully [1][2]. - error: Fails the action with an error message [1][2]. - ignore: Neither prints a warning nor fails the action [1][2]. This option does not inherently control whether the action runs on a failed job; that is a separate configuration concern [3]. To ensure that an artifact upload step executes even if previous steps in the job have failed, you must use the if: always() or if: failure() conditional expression [3]. Example usage: - uses: actions/upload-artifact@v7 if: always with: name: my-artifact path: path/to/files/ if-no-files-found: ignore In this configuration, the artifact step will always attempt to run (even if earlier steps failed) and will not error or warn if the specified files are missing [1][3][2].

Citations:


Upload fetched findings even when later pipeline stages fail.

main.py writes aquasec_scan.json before parsing, synchronization, and notification, but the artifact step is skipped after a failure by default. As written, the docs line that says every scan run uploads the artifact is only true when the whole job succeeds.

  • .github/workflows/aquasec-scan.yml#L136-L140: condition the upload on non-cancelled failures and suppress the missing-file warning.
  • docs/security/security.md#L46-L48: either narrow the wording to successful runs or update the docs after making upload failure-safe.
📍 Affects 2 files
  • .github/workflows/aquasec-scan.yml#L136-L140 (this comment)
  • docs/security/security.md#L46-L48
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/aquasec-scan.yml around lines 136 - 140, Update the
artifact upload step in .github/workflows/aquasec-scan.yml at lines 136-140 to
run on non-cancelled failures and suppress missing-file warnings. Update the
documentation wording in docs/security/security.md at lines 46-48 to accurately
state that every non-cancelled scan run uploads the artifact.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

commit='4673f916aae10438f725b946b8a3544f3ed144d3'

echo '== Commit metadata =='
git show -s --format='%H%n%s%n%n%b' "$commit"

echo
echo '== Commit diff (relevant files) =='
git show --format= -- .github/workflows/aquasec-scan.yml docs/security/security.md "$commit"

echo
echo '== Current workflow upload step =='
nl -ba .github/workflows/aquasec-scan.yml | sed -n '128,150p'

echo
echo '== Current documentation artifact section =='
nl -ba docs/security/security.md | sed -n '40,55p'

Length of output: 1028


🏁 Script executed:

#!/bin/bash
set -euo pipefail

commit='4673f916aae10438f725b946b8a3544f3ed144d3'

echo '== Files changed by commit =='
git diff-tree --no-commit-id --name-status -r "$commit"

echo
echo '== Full commit patch =='
git show --format= --find-renames "$commit"

echo
echo '== Current workflow artifact-upload section =='
sed -n '128,150p' .github/workflows/aquasec-scan.yml

echo
echo '== Current security documentation artifact section =='
sed -n '40,55p' docs/security/security.md

Length of output: 3120


@tmikula-dev, verified commit 4673f91: the revised documentation correctly limits artifact availability to successful scan completion, matching the workflow’s existing behavior. This resolves the finding.

6 changes: 6 additions & 0 deletions docs/security/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ flowchart TD

---

## Scan Artifact

When the scan step completes successfully, the fetched AquaSec findings are uploaded as a workflow artifact named `aquasec-night-scan`, containing a single `aquasec_scan.json` file with all findings across every severity. It provides a full snapshot of the repository's current security state for monitoring purposes.

---

## Key Benefits

- **Zero manual triage**: New findings from AquaSec scans automatically become Issues with severity, context, and links to the affected code.
Expand Down
8 changes: 8 additions & 0 deletions src/core/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
"""Pure utility functions"""

import hashlib
import json
import re
from datetime import datetime, timezone
from typing import Any

# Matches lines that start with 1-6 '#' followed by a space
_HEADING_RE = re.compile(r"^(#{1,6}\s)", re.MULTILINE)
Expand Down Expand Up @@ -49,6 +51,12 @@ def sha256_hex(text: str) -> str:
return hashlib.sha256(text.encode("utf-8"), usedforsecurity=False).hexdigest()


def write_json(path: str, data: Any) -> None:
"""Write *data* to *path* as indented JSON (UTF-8)."""
with open(path, "w", encoding="utf-8") as file:
json.dump(data, file, indent=2)


def normalize_path(path: str | None) -> str:
"""Normalize a file path to forward-slash, no leading ``./`` or ``/``."""
if not path:
Expand Down
2 changes: 2 additions & 0 deletions src/security/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class SecurityConfig:
project_org: str = ""
teams_webhook_url: str = ""
min_severity: str = MIN_SEVERITY_DEFAULT
scan_output: str = ""

@classmethod
def load(cls, args: argparse.Namespace) -> "SecurityConfig":
Expand Down Expand Up @@ -77,6 +78,7 @@ def load(cls, args: argparse.Namespace) -> "SecurityConfig":
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", ""),
min_severity=min_severity,
scan_output=args.scan_output,
)

def validate(self) -> None:
Expand Down
3 changes: 1 addition & 2 deletions src/security/issues/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,11 +377,10 @@ def _handle_new_child_issue(
if sync.dry_run:
if parent_issue is not None:
logging.info(
DRY_RUN_PREFIX + "Would create child issue for alert %d (rule: %s, severity: %s) linked to parent #%d",
DRY_RUN_PREFIX + "Would create child issue for alert %d (rule: %s, severity: %s)",
ctx.alert_number,
ctx.rule_id,
ctx.severity,
parent_issue.number,
)
sync.stats.children_linked += 1
else:
Expand Down
11 changes: 11 additions & 0 deletions src/security/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import shutil

from core.config import parse_runner_debug, setup_logging
from core.helpers import write_json

from security.alerts.aquasec_parser import AquaSecParser
from security.constants import LOGGING_PREFIX
Expand Down Expand Up @@ -86,6 +87,11 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
help="Teams Incoming Webhook URL. Falls back to $TEAMS_WEBHOOK_URL env var",
)
p.add_argument("--min-severity", default="", help="Minimum severity level for issue creation.")
p.add_argument(
"--scan-output",
default="",
help="Path to write the fetched AquaSec scan JSON. When omitted, no file is written.",
)
p.add_argument("--dry-run", action="store_true", help="Do not write issues; only print intended actions")
p.add_argument("--verbose", action="store_true", help="Verbose logs (also enabled by RUNNER_DEBUG=1)")
return p.parse_args(argv)
Expand Down Expand Up @@ -126,6 +132,11 @@ def main(argv: list[str] | None = None) -> int:
fetcher = ScanFetcher(bearer_token, config.aqua_repository_id)
scan_data = fetcher.fetch_findings()

# Write the raw scan JSON for monitoring when a path is provided
if config.scan_output:
write_json(config.scan_output, scan_data)
logger.info("%sScan findings written to %s", LOGGING_PREFIX, config.scan_output)

# Parse findings
parser = AquaSecParser(repo)
loaded_alerts = parser.parse(scan_data)
Expand Down
16 changes: 15 additions & 1 deletion tests/core/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
# limitations under the License.
#

import json

import pytest

from core.helpers import normalize_bullet_list, sanitize_markdown
from core.helpers import normalize_bullet_list, sanitize_markdown, write_json


# sanitize_markdown
Expand Down Expand Up @@ -96,3 +98,15 @@ def test_normalize_bullet_list_preserves_non_bullet_lines() -> None:
text = "Some intro text\n- https://example.com\n - https://other.com\nTrailing text"
result = normalize_bullet_list(text)
assert result == "Some intro text\n- https://example.com\n- https://other.com\nTrailing text"


# write_json


def test_write_json_writes_indented_content(tmp_path) -> None:
path = tmp_path / "aquasec_scan.json"
data = {"total": 2, "data": [{"a": 1}, {"b": 2}]}

write_json(str(path), data)

assert data == json.loads(path.read_text(encoding="utf-8"))
1 change: 1 addition & 0 deletions tests/security/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def _make_args(**kwargs) -> argparse.Namespace:
"project_org": "",
"teams_webhook_url": "",
"min_severity": "",
"scan_output": "",
}
defaults.update(kwargs)
return argparse.Namespace(**defaults)
Expand Down
25 changes: 25 additions & 0 deletions tests/security/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def test_parse_args_defaults():
assert args.issue_label == "scope:security"
assert args.dry_run is False
assert args.verbose is False
assert args.scan_output == ""


def test_parse_args_all_flags():
Expand All @@ -77,6 +78,7 @@ def test_parse_args_all_flags():
"--project-number", "42",
"--project-org", "other-org",
"--teams-webhook-url", "https://example.com/webhook",
"--scan-output", "aquasec_scan.json",
"--dry-run",
"--verbose",
])
Expand All @@ -86,6 +88,7 @@ def test_parse_args_all_flags():
assert args.project_number == "42"
assert args.project_org == "other-org"
assert args.teams_webhook_url == "https://example.com/webhook"
assert args.scan_output == "aquasec_scan.json"
assert args.dry_run is True
assert args.verbose is True

Expand Down Expand Up @@ -128,6 +131,28 @@ def test_pipeline_calls_notify(mocker):
mocks["notifier"].assert_called_once()


# main - scan output


def test_scan_output_writes_scan_data(mocker):
mocks = _mock_pipeline(mocker)
mocks["fetcher"].return_value.fetch_findings.return_value = {"total": 1, "data": [{"a": 1}]}
mock_write = mocker.patch("security.main.write_json")

main(["--repo", REPO, "--scan-output", "aquasec_scan.json"])

mock_write.assert_called_once_with("aquasec_scan.json", {"total": 1, "data": [{"a": 1}]})


def test_scan_output_skipped_when_not_provided(mocker):
_mock_pipeline(mocker)
mock_write = mocker.patch("security.main.write_json")

main(["--repo", REPO])

mock_write.assert_not_called()


def test_pipeline_call_order(mocker):
call_order: list[str] = []
mocker.patch.object(LabelChecker, "check_labels", side_effect=lambda: (call_order.append("check"), [])[-1])
Expand Down