From d3e581d2440b1b6aad2a6d3665687e3c7ed3bb9c Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:23:16 +0300 Subject: [PATCH 01/15] fix(ci): bound pentest DAST scan runtime so it cannot hang Nuclei had no time bound and stalled the run: wrap all scanners in a shell timeout (killed scans still yield partial results), add step timeout-minutes backstops, and pass nuclei -no-interactsh/-disable-update-check/-timeout/-retries to stop OOB polling and network waits from blocking. Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .github/workflows/scan-pentest.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/scan-pentest.yml b/.github/workflows/scan-pentest.yml index 101f631e881..3554376a2e6 100644 --- a/.github/workflows/scan-pentest.yml +++ b/.github/workflows/scan-pentest.yml @@ -87,19 +87,21 @@ jobs: run: python3 .github/workflows/scripts/pentest_ingest_scans.py || true - name: DAST — baseline scan + timeout-minutes: 20 env: JANS_URL: https://${{ env.JANS_FQDN }} run: | - docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD:/zap/wrk:rw" \ + timeout 900 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD:/zap/wrk:rw" \ ghcr.io/zaproxy/zaproxy:stable \ zap-baseline.py -t "$JANS_URL" -J zap.json -I || true - name: DAST — API scan (OpenAPI-seeded, full only) if: env.FULL_SCAN == 'true' + timeout-minutes: 20 run: | spec=$(jq -r '.openapi_specs[0] // empty' targets.json) if [ -n "$spec" ]; then - docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD:/zap/wrk:rw" \ + timeout 900 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD:/zap/wrk:rw" \ ghcr.io/zaproxy/zaproxy:stable \ zap-api-scan.py -t "$spec" -f openapi -J zap-api.json -I || true else @@ -107,11 +109,17 @@ jobs: fi - name: DAST — template scan + timeout-minutes: 25 # backstop; the shell timeout below should fire first run: | - docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD:/data:rw" \ + # -no-interactsh: skip OOB polling (can stall on egress); -disable-update-check + # + -timeout/-retries: bound network waits. Wrapped in `timeout` so a slow scan + # is killed and partial results are still used (report-only step). + timeout 900 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD:/data:rw" \ projectdiscovery/nuclei:latest \ -l /data/targets.txt -jsonl -o /data/nuclei.jsonl \ - -severity low,medium,high,critical || true + -severity low,medium,high,critical \ + -no-interactsh -disable-update-check -timeout 10 -retries 1 \ + || echo "nuclei ended (timeout or non-zero exit); using partial results" - name: Analysis (Messages API) if: env.PENTEST_AI_ENDPOINT != '' From 283c402be28fc207775c61d93f4f6761585f581f Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:27:48 +0300 Subject: [PATCH 02/15] fix(ci): make pentest scanners write output and keep scanning the target - Mount a world-writable dast/ dir for the scanner containers (they run as non-root); fixes ZAP 'AccessDenied /zap/wrk/zap.json' that dropped all ZAP findings from the report. - nuclei -no-mhe: a hardened IDP resets many probes and tripped the default max-host-error, skipping the whole target ('unresponsive ... skipped'). Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .github/workflows/scan-pentest.yml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/scan-pentest.yml b/.github/workflows/scan-pentest.yml index 3554376a2e6..c486bdc8aa2 100644 --- a/.github/workflows/scan-pentest.yml +++ b/.github/workflows/scan-pentest.yml @@ -76,7 +76,10 @@ jobs: PENTEST_TARGETS: targets.json run: | python3 .github/workflows/scripts/pentest_discover_endpoints.py - jq -r '.targets[]' targets.json > targets.txt + # Scanner containers run as non-root; give them a world-writable dir for + # inputs/outputs (fixes ZAP "AccessDenied /zap/wrk/zap.json"). + mkdir -p dast && chmod 777 dast + jq -r '.targets[]' targets.json > dast/targets.txt cat targets.json - name: Ingest existing scan results @@ -91,7 +94,7 @@ jobs: env: JANS_URL: https://${{ env.JANS_FQDN }} run: | - timeout 900 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD:/zap/wrk:rw" \ + timeout 900 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD/dast:/zap/wrk:rw" \ ghcr.io/zaproxy/zaproxy:stable \ zap-baseline.py -t "$JANS_URL" -J zap.json -I || true @@ -101,7 +104,7 @@ jobs: run: | spec=$(jq -r '.openapi_specs[0] // empty' targets.json) if [ -n "$spec" ]; then - timeout 900 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD:/zap/wrk:rw" \ + timeout 900 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD/dast:/zap/wrk:rw" \ ghcr.io/zaproxy/zaproxy:stable \ zap-api-scan.py -t "$spec" -f openapi -J zap-api.json -I || true else @@ -114,11 +117,14 @@ jobs: # -no-interactsh: skip OOB polling (can stall on egress); -disable-update-check # + -timeout/-retries: bound network waits. Wrapped in `timeout` so a slow scan # is killed and partial results are still used (report-only step). - timeout 900 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD:/data:rw" \ + # -no-mhe: don't drop the host after N errors — a hardened IDP resets many + # probe requests, which otherwise trips the default max-host-error (30) and + # skips the whole target. + timeout 900 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD/dast:/data:rw" \ projectdiscovery/nuclei:latest \ -l /data/targets.txt -jsonl -o /data/nuclei.jsonl \ -severity low,medium,high,critical \ - -no-interactsh -disable-update-check -timeout 10 -retries 1 \ + -no-interactsh -disable-update-check -timeout 10 -retries 1 -no-mhe \ || echo "nuclei ended (timeout or non-zero exit); using partial results" - name: Analysis (Messages API) @@ -130,8 +136,8 @@ jobs: --arg sys "$SYS" \ --slurpfile targets targets.json \ --slurpfile context context.json \ - --arg zap "$(cat zap.json 2>/dev/null || echo '{}')" \ - --arg nuclei "$(cat nuclei.jsonl 2>/dev/null || echo '')" \ + --arg zap "$(cat dast/zap.json 2>/dev/null || echo '{}')" \ + --arg nuclei "$(cat dast/nuclei.jsonl 2>/dev/null || echo '')" \ '{ model: $model, max_tokens: 4096, @@ -195,7 +201,7 @@ jobs: run: | pip install --quiet --require-hashes -r .github/workflows/scripts/requirements-pentest.txt python3 .github/workflows/scripts/pentest_report.py \ - --zap zap.json --nuclei nuclei.jsonl \ + --zap dast/zap.json --nuclei dast/nuclei.jsonl \ --context context.json --analysis analysis.json \ --meta meta.json --pdf \ --logo docs/assets/logo/janssen_project_transparent_630px_182px.png \ From fa44a2ebb0f97f193e66ba44ddb5df8558872dd0 Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:37:38 +0300 Subject: [PATCH 03/15] feat(sec): run full pentest template set, release-only Drop the nightly cron so scan-pentest runs on v** tags + dispatch only, and raise the scan time budgets (nuclei step 300m / shell 17400s) so the full nuclei template set runs to completion. Re-enable interactsh for OOB coverage; keep -no-mhe. Docs updated to release-gated. Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .github/workflows/scan-pentest.yml | 38 +++++++++------------- docs/contribute/ci-cd/security-scanning.md | 18 +++++----- docs/contribute/ci-cd/workflows.md | 2 +- 3 files changed, 26 insertions(+), 32 deletions(-) diff --git a/.github/workflows/scan-pentest.yml b/.github/workflows/scan-pentest.yml index c486bdc8aa2..e7fae1bcbf6 100644 --- a/.github/workflows/scan-pentest.yml +++ b/.github/workflows/scan-pentest.yml @@ -1,4 +1,4 @@ -# Nightly + tagged-release DAST pen-test against a live all-in-one instance. +# Tagged-release DAST pen-test against a live all-in-one instance. # # Brings up the same consul+vault+traefik+DB+AIO compose stack the terraform- # provider tests use, discovers the served edges, ingests existing scan output @@ -10,9 +10,6 @@ name: "Scan: Pen Test (DAST)" on: - schedule: - # after the nightly image republish (23:00) and nightly integration (04:00) - - cron: "0 5 * * *" push: tags: - "v**" @@ -90,21 +87,21 @@ jobs: run: python3 .github/workflows/scripts/pentest_ingest_scans.py || true - name: DAST — baseline scan - timeout-minutes: 20 + timeout-minutes: 30 env: JANS_URL: https://${{ env.JANS_FQDN }} run: | - timeout 900 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD/dast:/zap/wrk:rw" \ + timeout 1500 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD/dast:/zap/wrk:rw" \ ghcr.io/zaproxy/zaproxy:stable \ zap-baseline.py -t "$JANS_URL" -J zap.json -I || true - name: DAST — API scan (OpenAPI-seeded, full only) if: env.FULL_SCAN == 'true' - timeout-minutes: 20 + timeout-minutes: 65 run: | spec=$(jq -r '.openapi_specs[0] // empty' targets.json) if [ -n "$spec" ]; then - timeout 900 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD/dast:/zap/wrk:rw" \ + timeout 3600 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD/dast:/zap/wrk:rw" \ ghcr.io/zaproxy/zaproxy:stable \ zap-api-scan.py -t "$spec" -f openapi -J zap-api.json -I || true else @@ -112,19 +109,19 @@ jobs: fi - name: DAST — template scan - timeout-minutes: 25 # backstop; the shell timeout below should fire first + timeout-minutes: 300 # backstop; the shell timeout below fires first run: | - # -no-interactsh: skip OOB polling (can stall on egress); -disable-update-check - # + -timeout/-retries: bound network waits. Wrapped in `timeout` so a slow scan - # is killed and partial results are still used (report-only step). - # -no-mhe: don't drop the host after N errors — a hardened IDP resets many - # probe requests, which otherwise trips the default max-host-error (30) and - # skips the whole target. - timeout 900 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD/dast:/data:rw" \ + # Release-only: run the FULL template set to completion. interactsh stays + # enabled for OOB (SSRF/RCE) coverage. -no-mhe keeps scanning a hardened + # IDP that resets many probes (default max-host-error would skip the host). + # -disable-update-check + -timeout/-retries bound per-request waits. Wrapped + # in `timeout` (< the step backstop) so a true hang still yields partial + # results and a green step. + timeout 17400 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD/dast:/data:rw" \ projectdiscovery/nuclei:latest \ -l /data/targets.txt -jsonl -o /data/nuclei.jsonl \ -severity low,medium,high,critical \ - -no-interactsh -disable-update-check -timeout 10 -retries 1 -no-mhe \ + -disable-update-check -timeout 10 -retries 1 -no-mhe \ || echo "nuclei ended (timeout or non-zero exit); using partial results" - name: Analysis (Messages API) @@ -174,12 +171,9 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | # Target release the report describes and uploads to: - # tag push -> the vX.Y.Z release; scheduled nightly -> the nightly - # release; ad-hoc dispatch -> none (artifact only). + # tag push -> the vX.Y.Z release; ad-hoc dispatch -> none (artifact only). if [ "$REF_TYPE" = "tag" ]; then RELEASE="$REF_NAME" - elif [ "$EVENT" = "schedule" ]; then - RELEASE="nightly" else RELEASE="" fi @@ -225,7 +219,7 @@ jobs: uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - name: Sign and upload report to release - # Runs for tagged releases and scheduled nightly (both have a release). + # Runs for tagged releases (dispatch produces the artifact only). if: steps.meta.outputs.release != '' env: # Publishing to an existing published release needs a PAT; GITHUB_TOKEN diff --git a/docs/contribute/ci-cd/security-scanning.md b/docs/contribute/ci-cd/security-scanning.md index a75f73d9c7d..08b02077a37 100644 --- a/docs/contribute/ci-cd/security-scanning.md +++ b/docs/contribute/ci-cd/security-scanning.md @@ -18,12 +18,13 @@ output, and where results land, then describes the pen-test that correlates them | Sonar | `scan-sonar.yml` | quality + security hotspots per module | Sonar report | SonarCloud (off-platform) | | Scorecard | `scan-scorecard.yml` | supply-chain posture | SARIF | code scanning + artifact + OpenSSF | | SBOM (Parlay + sbomqs) | `scan-sbom.yml` | dependency graph + compliance | signed JSON | release assets | -| Pen-test (DAST) | `scan-pentest.yml` | live endpoints | PDF/JSON/MD/SARIF | artifact; cosign-signed to the nightly & tagged (`vX.Y.Z`) releases via `MOAUTO_WORKFLOW_TOKEN` | +| Pen-test (DAST) | `scan-pentest.yml` | live endpoints | PDF/JSON/MD/SARIF | artifact; cosign-signed to the tagged (`vX.Y.Z`) release via `MOAUTO_WORKFLOW_TOKEN` | ## Pen-test (DAST) -`scan-pentest.yml` runs nightly and on tagged releases. It is **report-only** — it -never fails the build. +`scan-pentest.yml` runs on tagged releases (`v**`) and manual dispatch. It runs the +full DAST template set to completion, so it is release-gated rather than nightly. It +is **report-only** — it never fails the build. Flow: @@ -42,12 +43,11 @@ Flow: Absent the secrets, the scan is DAST-only. 6. **Report** — `scripts/pentest_report.py` merges everything into `pentest-report.{pdf,json,md,sarif}`. The PDF carries the Janssen logo header - and a run-metadata block (target release — `nightly` vs `vX.Y.Z` — AIO image, - persistence, scan type, trigger, commit and run URL) above the severity-ranked - findings table. All formats upload as a workflow artifact; for scheduled - nightly and tagged-release runs they are also cosign-signed and attached to the - corresponding release (the `nightly` prerelease or the `vX.Y.Z` release). - Ad-hoc `workflow_dispatch` runs produce the artifact only. + and a run-metadata block (target release `vX.Y.Z`, AIO image, persistence, scan + type, trigger, commit and run URL) above the severity-ranked findings table. All + formats upload as a workflow artifact; for tagged-release runs they are also + cosign-signed and attached to the `vX.Y.Z` release. Manual `workflow_dispatch` + runs produce the artifact only. ### Configuration diff --git a/docs/contribute/ci-cd/workflows.md b/docs/contribute/ci-cd/workflows.md index 6cbcf590262..342bb99a927 100644 --- a/docs/contribute/ci-cd/workflows.md +++ b/docs/contribute/ci-cd/workflows.md @@ -45,7 +45,7 @@ One row per workflow under `.github/workflows/`. See | `scan-sonar.yml` | push/PR, dispatch | SonarCloud quality/security scan per module. | | `scan-scorecard.yml` | push main, weekly | OpenSSF Scorecard. | | `scan-sbom.yml` | tag `v**`/`nightly` | enriched SBOM + compliance reports to release assets. | -| `scan-pentest.yml` | cron 05:00, tag `v**`, dispatch | DAST pen-test against the live AIO (report-only). | +| `scan-pentest.yml` | tag `v**`, dispatch | full DAST pen-test against the live AIO (report-only). | ## Ops From 0eadba6121e9d055acd527a7f8d4064fecd4e30b Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:44:48 +0300 Subject: [PATCH 04/15] feat(sec): run pentest on nightly + release, chained after image publish Trigger scan-pentest via workflow_run on 'Build Docker Images' (gated to nightly and v** head_branch) so it scans the freshly published all-in-one image for both nightly and tagged releases; manual dispatch still supported. Per-release concurrency group with cancel-in-progress:false so a long full scan is never cancelled. Report uploads to the nightly or vX.Y.Z release accordingly. Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .github/workflows/scan-pentest.yml | 35 ++++++++++++++-------- docs/contribute/ci-cd/architecture.md | 3 +- docs/contribute/ci-cd/security-scanning.md | 19 ++++++------ docs/contribute/ci-cd/workflows.md | 2 +- 4 files changed, 35 insertions(+), 24 deletions(-) diff --git a/.github/workflows/scan-pentest.yml b/.github/workflows/scan-pentest.yml index e7fae1bcbf6..1fd41454fe6 100644 --- a/.github/workflows/scan-pentest.yml +++ b/.github/workflows/scan-pentest.yml @@ -1,4 +1,5 @@ -# Tagged-release DAST pen-test against a live all-in-one instance. +# Nightly and tagged-release DAST pen-test against a live all-in-one instance. +# Chained after "Build Docker Images" so it scans the freshly published image. # # Brings up the same consul+vault+traefik+DB+AIO compose stack the terraform- # provider tests use, discovers the served edges, ingests existing scan output @@ -10,9 +11,11 @@ name: "Scan: Pen Test (DAST)" on: - push: - tags: - - "v**" + # Chained after the images are published for a nightly or vX.Y.Z release, so the + # freshly built all-in-one image is what gets scanned (not the prior one). + workflow_run: + workflows: ["Build Docker Images"] + types: [completed] workflow_dispatch: inputs: aio_image_tag: @@ -28,13 +31,20 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # Per-release group; never cancel an in-progress scan (completion matters). + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.ref }} + cancel-in-progress: false jobs: pentest: name: DAST pen-test - if: github.repository == 'JanssenProject/jans' + # Manual dispatch, or a successful images build for a nightly / vX.Y.Z release. + if: >- + github.repository == 'JanssenProject/jans' && + (github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + (github.event.workflow_run.head_branch == 'nightly' || + startsWith(github.event.workflow_run.head_branch, 'v')))) runs-on: ubuntu-latest permissions: contents: read @@ -83,7 +93,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} PENTEST_CONTEXT: context.json - PENTEST_RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }} + PENTEST_RELEASE_TAG: ${{ github.event.workflow_run.head_branch || 'nightly' }} run: python3 .github/workflows/scripts/pentest_ingest_scans.py || true - name: DAST — baseline scan @@ -166,14 +176,13 @@ jobs: id: meta env: EVENT: ${{ github.event_name }} - REF_TYPE: ${{ github.ref_type }} - REF_NAME: ${{ github.ref_name }} + WF_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | # Target release the report describes and uploads to: - # tag push -> the vX.Y.Z release; ad-hoc dispatch -> none (artifact only). - if [ "$REF_TYPE" = "tag" ]; then - RELEASE="$REF_NAME" + # workflow_run (nightly / vX.Y.Z) -> that release; dispatch -> none (artifact only). + if [ "$EVENT" = "workflow_run" ]; then + RELEASE="$WF_HEAD_BRANCH" else RELEASE="" fi diff --git a/docs/contribute/ci-cd/architecture.md b/docs/contribute/ci-cd/architecture.md index 1b82a79c688..5d9557a90f1 100644 --- a/docs/contribute/ci-cd/architecture.md +++ b/docs/contribute/ci-cd/architecture.md @@ -40,6 +40,7 @@ flowchart TD BP -->|workflow_run: completed| BPK[build-packages.yml] BDI -->|workflow_run: completed| TA[test-tf-authz-action.yml] BDI -->|workflow_run: completed| TJ[test-tf-authz-jwt.yml] + BDI -->|workflow_run: nightly/v*| PT[scan-pentest.yml] REL[release published] -.waits on run.-> BD[build-docs.yml] ``` @@ -48,7 +49,7 @@ flowchart TD | Mechanism | Where | Note | |---|---|---| | tag push (PAT) | `release-trigger`, `build-nightly` | a `GITHUB_TOKEN`-pushed tag does not trigger workflows, so a PAT (`MOAUTO_WORKFLOW_TOKEN`) pushes the tag | -| `workflow_run` | `build-docker-images`, `build-packages` listen on `Build & Publish`; tf-authz tests listen on `Build Docker Images` | loose coupling by workflow `name:`; renaming a `name:` breaks its listeners | +| `workflow_run` | `build-docker-images`, `build-packages` listen on `Build & Publish`; tf-authz tests and `scan-pentest` (nightly/`v*`) listen on `Build Docker Images` | loose coupling by workflow `name:`; renaming a `name:` breaks its listeners | | `workflow_call` | `release-cedarling` (reusable), `slsa-github-generator` | true reusable workflows | | `workflow_dispatch` | most build/release workflows | manual entry points | diff --git a/docs/contribute/ci-cd/security-scanning.md b/docs/contribute/ci-cd/security-scanning.md index 08b02077a37..edccbebe211 100644 --- a/docs/contribute/ci-cd/security-scanning.md +++ b/docs/contribute/ci-cd/security-scanning.md @@ -18,13 +18,14 @@ output, and where results land, then describes the pen-test that correlates them | Sonar | `scan-sonar.yml` | quality + security hotspots per module | Sonar report | SonarCloud (off-platform) | | Scorecard | `scan-scorecard.yml` | supply-chain posture | SARIF | code scanning + artifact + OpenSSF | | SBOM (Parlay + sbomqs) | `scan-sbom.yml` | dependency graph + compliance | signed JSON | release assets | -| Pen-test (DAST) | `scan-pentest.yml` | live endpoints | PDF/JSON/MD/SARIF | artifact; cosign-signed to the tagged (`vX.Y.Z`) release via `MOAUTO_WORKFLOW_TOKEN` | +| Pen-test (DAST) | `scan-pentest.yml` | live endpoints | PDF/JSON/MD/SARIF | artifact; cosign-signed to the nightly & tagged (`vX.Y.Z`) releases via `MOAUTO_WORKFLOW_TOKEN` | ## Pen-test (DAST) -`scan-pentest.yml` runs on tagged releases (`v**`) and manual dispatch. It runs the -full DAST template set to completion, so it is release-gated rather than nightly. It -is **report-only** — it never fails the build. +`scan-pentest.yml` runs after "Build Docker Images" completes for a nightly or +tagged (`v**`) release — so it scans the freshly published all-in-one image — and +on manual dispatch. It runs the full DAST template set to completion. It is +**report-only** — it never fails the build. Flow: @@ -43,11 +44,11 @@ Flow: Absent the secrets, the scan is DAST-only. 6. **Report** — `scripts/pentest_report.py` merges everything into `pentest-report.{pdf,json,md,sarif}`. The PDF carries the Janssen logo header - and a run-metadata block (target release `vX.Y.Z`, AIO image, persistence, scan - type, trigger, commit and run URL) above the severity-ranked findings table. All - formats upload as a workflow artifact; for tagged-release runs they are also - cosign-signed and attached to the `vX.Y.Z` release. Manual `workflow_dispatch` - runs produce the artifact only. + and a run-metadata block (target release — `nightly` or `vX.Y.Z` — AIO image, + persistence, scan type, trigger, commit and run URL) above the severity-ranked + findings table. All formats upload as a workflow artifact; for nightly and + tagged-release runs they are also cosign-signed and attached to the corresponding + release. Manual `workflow_dispatch` runs produce the artifact only. ### Configuration diff --git a/docs/contribute/ci-cd/workflows.md b/docs/contribute/ci-cd/workflows.md index 342bb99a927..05be52359e0 100644 --- a/docs/contribute/ci-cd/workflows.md +++ b/docs/contribute/ci-cd/workflows.md @@ -45,7 +45,7 @@ One row per workflow under `.github/workflows/`. See | `scan-sonar.yml` | push/PR, dispatch | SonarCloud quality/security scan per module. | | `scan-scorecard.yml` | push main, weekly | OpenSSF Scorecard. | | `scan-sbom.yml` | tag `v**`/`nightly` | enriched SBOM + compliance reports to release assets. | -| `scan-pentest.yml` | tag `v**`, dispatch | full DAST pen-test against the live AIO (report-only). | +| `scan-pentest.yml` | `workflow_run` (Build Docker Images) for nightly/`v**`, dispatch | full DAST pen-test against the live AIO (report-only). | ## Ops From e5c9f71551513d35f5b86179659743ef9a378446 Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:49:57 +0300 Subject: [PATCH 05/15] feat(sec): scan the version-tagged AIO image on vX.Y.Z releases Resolve AIO_IMAGE_TAG from the release ref: a vX.Y.Z head_branch maps to all-in-one: (build-docker-images tags images without the leading v), nightly uses the moving nightly image, and a dispatch aio_image_tag input still overrides both. Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .github/workflows/scan-pentest.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/scan-pentest.yml b/.github/workflows/scan-pentest.yml index 1fd41454fe6..3a7a035957a 100644 --- a/.github/workflows/scan-pentest.yml +++ b/.github/workflows/scan-pentest.yml @@ -74,6 +74,26 @@ jobs: - name: Map FQDN to localhost (traefik publishes :443 on the runner) run: echo "127.0.0.1 ${JANS_FQDN}" | sudo tee -a /etc/hosts > /dev/null + - name: Resolve AIO image + env: + EVENT: ${{ github.event_name }} + WF_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + INPUT_TAG: ${{ github.event.inputs.aio_image_tag }} + run: | + # dispatch input wins; a vX.Y.Z release maps to the version-tagged image + # (build-docker-images tags all-in-one:, no leading v); nightly + # and everything else use the moving nightly image. + repo=ghcr.io/janssenproject/jans/all-in-one + if [ -n "$INPUT_TAG" ]; then + TAG="$INPUT_TAG" + elif [ "$EVENT" = "workflow_run" ] && [ "${WF_HEAD_BRANCH#v}" != "$WF_HEAD_BRANCH" ]; then + TAG="${repo}:${WF_HEAD_BRANCH#v}" + else + TAG="${repo}:0.0.0-nightly" + fi + echo "AIO_IMAGE_TAG=${TAG}" >> "$GITHUB_ENV" + echo "resolved AIO_IMAGE_TAG=${TAG}" + - name: Bring up AIO target run: bash automation/ci/run_aio_for_tf.sh From 8b4edda74b31112cc46ac7d4e7748b05e4117a07 Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:54:03 +0300 Subject: [PATCH 06/15] fix(ci): let nuclei install templates (drop -duc) -disable-update-check also skips the first-run template install; the image ships none, so the scan aborted with 'no templates provided'. Remove it so nuclei downloads the template set before scanning. Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .github/workflows/scan-pentest.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/scan-pentest.yml b/.github/workflows/scan-pentest.yml index 3a7a035957a..06ff6b9e55d 100644 --- a/.github/workflows/scan-pentest.yml +++ b/.github/workflows/scan-pentest.yml @@ -141,17 +141,18 @@ jobs: - name: DAST — template scan timeout-minutes: 300 # backstop; the shell timeout below fires first run: | - # Release-only: run the FULL template set to completion. interactsh stays - # enabled for OOB (SSRF/RCE) coverage. -no-mhe keeps scanning a hardened - # IDP that resets many probes (default max-host-error would skip the host). - # -disable-update-check + -timeout/-retries bound per-request waits. Wrapped - # in `timeout` (< the step backstop) so a true hang still yields partial - # results and a green step. + # Release-only: run the FULL template set to completion. The image ships no + # templates, so nuclei installs them on first run (do NOT pass -duc, which + # skips that install). interactsh stays enabled for OOB (SSRF/RCE) coverage. + # -no-mhe keeps scanning a hardened IDP that resets many probes (default + # max-host-error would skip the host). -timeout/-retries bound per-request + # waits. Wrapped in `timeout` (< the step backstop) so a true hang still + # yields partial results and a green step. timeout 17400 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD/dast:/data:rw" \ projectdiscovery/nuclei:latest \ -l /data/targets.txt -jsonl -o /data/nuclei.jsonl \ -severity low,medium,high,critical \ - -disable-update-check -timeout 10 -retries 1 -no-mhe \ + -timeout 10 -retries 1 -no-mhe \ || echo "nuclei ended (timeout or non-zero exit); using partial results" - name: Analysis (Messages API) From 98e5892e8f6d1dd5d992a64449e0a025a4a32bcc Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:14:24 +0300 Subject: [PATCH 07/15] fix(ci): harden pentest report parsing, pin nuclei image, doc accuracy - pentest_report: load_zap/load_analysis tolerate truncated JSON (a timeout- killed scanner can leave a partial report) so the report step still runs. - pin nuclei to an immutable digest (v3.11.1); templates still fetch fresh. - docs: scan runs within a bounded window and may keep partial results. Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .github/workflows/scan-pentest.yml | 4 +++- .github/workflows/scripts/pentest_report.py | 14 ++++++++++++-- docs/contribute/ci-cd/security-scanning.md | 6 ++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/scan-pentest.yml b/.github/workflows/scan-pentest.yml index 06ff6b9e55d..17b3dbe8a9c 100644 --- a/.github/workflows/scan-pentest.yml +++ b/.github/workflows/scan-pentest.yml @@ -148,8 +148,10 @@ jobs: # max-host-error would skip the host). -timeout/-retries bound per-request # waits. Wrapped in `timeout` (< the step backstop) so a true hang still # yields partial results and a green step. + # Pinned to an immutable digest (v3.11.1); the template set still downloads + # fresh at runtime, so coverage stays current while the engine is reproducible. timeout 17400 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD/dast:/data:rw" \ - projectdiscovery/nuclei:latest \ + projectdiscovery/nuclei@sha256:582d5546902e67052097cb2d07296c642d50a1afc5e44623cb038845df9a32eb \ -l /data/targets.txt -jsonl -o /data/nuclei.jsonl \ -severity low,medium,high,critical \ -timeout 10 -retries 1 -no-mhe \ diff --git a/.github/workflows/scripts/pentest_report.py b/.github/workflows/scripts/pentest_report.py index 7c151b1ae37..a79fb901cf3 100755 --- a/.github/workflows/scripts/pentest_report.py +++ b/.github/workflows/scripts/pentest_report.py @@ -34,7 +34,13 @@ def norm_sev(s): def load_zap(path): if not path or not os.path.exists(path): return [] - doc = json.load(open(path)) + try: + doc = json.load(open(path)) + except (json.JSONDecodeError, OSError) as e: + # A timeout-killed ZAP can leave a truncated report; treat as no findings + # so the report step still runs. + print(f"warn: ZAP report unreadable ({e}); treating as unavailable", file=sys.stderr) + return [] out = [] for site in doc.get("site", []): for a in site.get("alerts", []): @@ -76,7 +82,11 @@ def load_nuclei(path): def load_analysis(path): if not path or not os.path.exists(path): return [] - doc = json.load(open(path)) + try: + doc = json.load(open(path)) + except (json.JSONDecodeError, OSError) as e: + print(f"warn: analysis report unreadable ({e}); treating as unavailable", file=sys.stderr) + return [] out = [] for f in doc.get("findings", []): out.append({ diff --git a/docs/contribute/ci-cd/security-scanning.md b/docs/contribute/ci-cd/security-scanning.md index edccbebe211..36a87bb8f56 100644 --- a/docs/contribute/ci-cd/security-scanning.md +++ b/docs/contribute/ci-cd/security-scanning.md @@ -24,8 +24,10 @@ output, and where results land, then describes the pen-test that correlates them `scan-pentest.yml` runs after "Build Docker Images" completes for a nightly or tagged (`v**`) release — so it scans the freshly published all-in-one image — and -on manual dispatch. It runs the full DAST template set to completion. It is -**report-only** — it never fails the build. +on manual dispatch. It runs the full DAST template set within a bounded time +window (a per-scan shell timeout under a step `timeout-minutes` backstop); if the +limit is reached the scan stops and the report is built from partial results. It +is **report-only** — it never fails the build. Flow: From a8afa8bcf0d1f73507e3b3457805c22a18988d24 Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:29:12 +0300 Subject: [PATCH 08/15] feat(sec): pentest MYSQL+PGSQL matrix with one consolidated report - Split into a scan matrix (MYSQL, PGSQL) + a report job that merges both backends into a single report (new Backend column in pdf/md/sarif). - Review fixes: gate workflow_run on event==workflow_run + same-repo head_repository (block fork PR image builds); report commit uses workflow_run.head_sha; validate dispatch aio_image_tag before $GITHUB_ENV; skip SBOM ingest when there is no release (manual dispatch); tolerate truncated zap/analysis JSON in the report; drop stale 'Release-only' comment. Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .github/workflows/scan-pentest.yml | 233 ++++++++++++------ .../workflows/scripts/pentest_ingest_scans.py | 6 +- .github/workflows/scripts/pentest_report.py | 33 ++- docs/contribute/ci-cd/security-scanning.md | 30 ++- docs/contribute/ci-cd/workflows.md | 2 +- 5 files changed, 202 insertions(+), 102 deletions(-) diff --git a/.github/workflows/scan-pentest.yml b/.github/workflows/scan-pentest.yml index 17b3dbe8a9c..9307ca2e342 100644 --- a/.github/workflows/scan-pentest.yml +++ b/.github/workflows/scan-pentest.yml @@ -1,12 +1,12 @@ # Nightly and tagged-release DAST pen-test against a live all-in-one instance. # Chained after "Build Docker Images" so it scans the freshly published image. # -# Brings up the same consul+vault+traefik+DB+AIO compose stack the terraform- -# provider tests use, discovers the served edges, ingests existing scan output -# (code-scanning alerts + SBOM) for correlation, runs open DAST tooling -# (baseline + API + template scans), optionally forwards results to a pluggable -# analysis endpoint (configured via secrets; degrades to DAST-only if unset), and -# publishes a consolidated report. Report-only: it never fails the build. +# The `scan` job runs the DAST tooling (ZAP baseline + optional API scan + full +# nuclei template set) against a fresh AIO for each persistence backend (MYSQL, +# PGSQL) in parallel, uploading raw scanner output. The `report` job ingests +# existing scan output (code-scanning alerts + SBOM) for correlation, optionally +# forwards results to a pluggable analysis endpoint, and publishes ONE +# consolidated report (PDF/JSON/MD/SARIF). Report-only: it never fails the build. name: "Scan: Pen Test (DAST)" @@ -36,30 +36,31 @@ concurrency: cancel-in-progress: false jobs: - pentest: - name: DAST pen-test - # Manual dispatch, or a successful images build for a nightly / vX.Y.Z release. + scan: + name: DAST scan (${{ matrix.persistence }}) + # Manual dispatch, or a successful RELEASE-chain images build (workflow_run + # originating from this repo) for a nightly / vX.Y.Z ref. The event + repo + # checks stop a fork PR's image build from launching a scan. if: >- github.repository == 'JanssenProject/jans' && (github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'workflow_run' && + github.event.workflow_run.head_repository.full_name == github.repository && (github.event.workflow_run.head_branch == 'nightly' || startsWith(github.event.workflow_run.head_branch, 'v')))) runs-on: ubuntu-latest permissions: contents: read - security-events: read # pentest_ingest_scans.py lists code-scanning alerts via github.token - id-token: write # keyless cosign sign-blob for the release report bundles + strategy: + fail-fast: false + matrix: + persistence: [MYSQL, PGSQL] env: - JANS_FQDN: pentest-${{ github.run_id }}.jans.test - JANS_PERSISTENCE: MYSQL + JANS_FQDN: pentest-${{ github.run_id }}-${{ matrix.persistence }}.jans.test + JANS_PERSISTENCE: ${{ matrix.persistence }} AIO_IMAGE_TAG: ${{ github.event.inputs.aio_image_tag || 'ghcr.io/janssenproject/jans/all-in-one:0.0.0-nightly' }} FULL_SCAN: ${{ github.event.inputs.full_scan || 'false' }} - # Present only when configured; the analysis step is skipped when empty. - PENTEST_AI_ENDPOINT: ${{ secrets.PENTEST_AI_ENDPOINT }} - PENTEST_AI_TOKEN: ${{ secrets.PENTEST_AI_TOKEN }} - PENTEST_AI_MODEL: ${{ vars.PENTEST_AI_MODEL || 'claude-opus-5' }} - PENTEST_AI_API_VERSION: ${{ vars.PENTEST_AI_API_VERSION || '2023-06-01' }} steps: - name: Harden Runner uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 @@ -85,6 +86,12 @@ jobs: # and everything else use the moving nightly image. repo=ghcr.io/janssenproject/jans/all-in-one if [ -n "$INPUT_TAG" ]; then + # Untrusted dispatch input: reject anything but a plain image reference + # before it reaches $GITHUB_ENV (guards against env-file injection). + case "$INPUT_TAG" in + *[!A-Za-z0-9._:/@-]*) + echo "::error::invalid aio_image_tag (unexpected characters)"; exit 1 ;; + esac TAG="$INPUT_TAG" elif [ "$EVENT" = "workflow_run" ] && [ "${WF_HEAD_BRANCH#v}" != "$WF_HEAD_BRANCH" ]; then TAG="${repo}:${WF_HEAD_BRANCH#v}" @@ -109,13 +116,6 @@ jobs: jq -r '.targets[]' targets.json > dast/targets.txt cat targets.json - - name: Ingest existing scan results - env: - GH_TOKEN: ${{ github.token }} - PENTEST_CONTEXT: context.json - PENTEST_RELEASE_TAG: ${{ github.event.workflow_run.head_branch || 'nightly' }} - run: python3 .github/workflows/scripts/pentest_ingest_scans.py || true - - name: DAST — baseline scan timeout-minutes: 30 env: @@ -141,7 +141,7 @@ jobs: - name: DAST — template scan timeout-minutes: 300 # backstop; the shell timeout below fires first run: | - # Release-only: run the FULL template set to completion. The image ships no + # Run the FULL template set within a bounded window. The image ships no # templates, so nuclei installs them on first run (do NOT pass -duc, which # skips that install). interactsh stays enabled for OOB (SSRF/RCE) coverage. # -no-mhe keeps scanning a hardened IDP that resets many probes (default @@ -157,26 +157,139 @@ jobs: -timeout 10 -retries 1 -no-mhe \ || echo "nuclei ended (timeout or non-zero exit); using partial results" + - name: Upload raw scan output + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: pentest-raw-${{ matrix.persistence }} + path: dast/ + retention-days: 7 + + - name: Collect target logs + if: always() + run: | + mkdir -p aio-logs + docker logs traefik > aio-logs/traefik.log 2>&1 || true + docker ps -a > aio-logs/containers.txt 2>&1 || true + + - name: Upload target logs + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: pentest-aio-logs-${{ matrix.persistence }} + path: aio-logs/ + retention-days: 7 + + report: + name: Consolidated report + needs: scan + if: ${{ always() && needs.scan.result != 'skipped' }} + runs-on: ubuntu-latest + permissions: + contents: read + security-events: read # pentest_ingest_scans.py lists code-scanning alerts via github.token + id-token: write # keyless cosign sign-blob for the release report bundles + env: + FULL_SCAN: ${{ github.event.inputs.full_scan || 'false' }} + PENTEST_AI_ENDPOINT: ${{ secrets.PENTEST_AI_ENDPOINT }} + PENTEST_AI_TOKEN: ${{ secrets.PENTEST_AI_TOKEN }} + PENTEST_AI_MODEL: ${{ vars.PENTEST_AI_MODEL || 'claude-opus-5' }} + PENTEST_AI_API_VERSION: ${{ vars.PENTEST_AI_API_VERSION || '2023-06-01' }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download raw scan output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: pentest-raw-* + path: raw + + - name: Ingest existing scan results + env: + GH_TOKEN: ${{ github.token }} + PENTEST_CONTEXT: context.json + # Empty on manual dispatch -> SBOM ingest is skipped (no release to pull). + PENTEST_RELEASE_TAG: ${{ github.event.workflow_run.head_branch }} + run: python3 .github/workflows/scripts/pentest_ingest_scans.py || true + + - name: Compute run metadata + id: meta + env: + EVENT: ${{ github.event_name }} + WF_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + WF_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + INPUT_TAG: ${{ github.event.inputs.aio_image_tag }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + # Report describes/uploads to: workflow_run (nightly / vX.Y.Z) -> that + # release; dispatch -> none (artifact only). The scanned commit is the + # triggering build's head_sha (GITHUB_SHA here is the default branch). + if [ "$EVENT" = "workflow_run" ]; then RELEASE="$WF_HEAD_BRANCH"; else RELEASE=""; fi + echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" + + repo=ghcr.io/janssenproject/jans/all-in-one + if [ -n "$INPUT_TAG" ]; then IMAGE="$INPUT_TAG" + elif [ "$EVENT" = "workflow_run" ] && [ "${WF_HEAD_BRANCH#v}" != "$WF_HEAD_BRANCH" ]; then IMAGE="${repo}:${WF_HEAD_BRANCH#v}" + else IMAGE="${repo}:0.0.0-nightly"; fi + + COMMIT="${WF_HEAD_SHA:-$GITHUB_SHA}" + if [ "$FULL_SCAN" = "true" ]; then SCAN="full"; else SCAN="baseline"; fi + + # Consolidated inputs: one --input per backend that produced output. + INPUTS="" + PERS="" + for b in MYSQL PGSQL; do + d="raw/pentest-raw-$b" + if [ -d "$d" ]; then + INPUTS="$INPUTS --input $b $d/zap.json $d/nuclei.jsonl" + PERS="${PERS:+$PERS,}$b" + fi + done + echo "PENTEST_INPUTS=$INPUTS" >> "$GITHUB_ENV" + + jq -n \ + --arg target "${RELEASE:-ad-hoc dispatch}" \ + --arg image "$IMAGE" \ + --arg persistence "${PERS:-unknown}" \ + --arg scan "$SCAN" \ + --arg commit "$COMMIT" \ + --arg event "$EVENT" \ + --arg run_url "$RUN_URL" \ + '{target:$target, image:$image, persistence:$persistence, scan:$scan, commit:$commit, event:$event, run_url:$run_url}' \ + > meta.json + cat meta.json + + - name: Build DAST report (pre-analysis) + run: | + # shellcheck disable=SC2086 + python3 .github/workflows/scripts/pentest_report.py \ + $PENTEST_INPUTS --context context.json --meta meta.json \ + --prefix pentest-report + - name: Analysis (Messages API) if: env.PENTEST_AI_ENDPOINT != '' run: | - SYS='You are a security analyst. Given DAST results and prior scan context, return ONLY minified JSON of the form {"findings":[{"title":string,"severity":"critical|high|medium|low|info","location":string,"detail":string}]}. No prose, no code fences.' + SYS='You are a security analyst. Given consolidated DAST findings and prior scan context, return ONLY minified JSON of the form {"findings":[{"title":string,"severity":"critical|high|medium|low|info","location":string,"detail":string}]}. No prose, no code fences.' jq -n \ --arg model "$PENTEST_AI_MODEL" \ --arg sys "$SYS" \ - --slurpfile targets targets.json \ + --slurpfile report pentest-report.json \ --slurpfile context context.json \ - --arg zap "$(cat dast/zap.json 2>/dev/null || echo '{}')" \ - --arg nuclei "$(cat dast/nuclei.jsonl 2>/dev/null || echo '')" \ '{ model: $model, max_tokens: 4096, system: $sys, messages: [ { role: "user", content: ( - "targets:\n" + ($targets[0] | tojson) + - "\n\ncontext:\n" + ($context[0] | tojson) + - "\n\nzap:\n" + $zap + - "\n\nnuclei:\n" + $nuclei ) } ] + "dast_findings:\n" + ($report[0] | tojson) + + "\n\ncontext:\n" + ($context[0] | tojson) ) } ] }' > analysis-request.json if curl -sf --max-time 300 -X POST "$PENTEST_AI_ENDPOINT" \ @@ -184,7 +297,6 @@ jobs: -H "anthropic-version: ${PENTEST_AI_API_VERSION}" \ -H "content-type: application/json" \ --data @analysis-request.json -o ai-response.json; then - # Extract the model's text (the findings JSON) and strip any code fences. jq -r '.content[0].text // empty' ai-response.json \ | sed -e 's/^```json//' -e 's/^```//' -e 's/```$//' > analysis.json || true jq -e '.findings' analysis.json > /dev/null 2>&1 || { @@ -195,40 +307,12 @@ jobs: echo "analysis endpoint unavailable; continuing DAST-only" fi - - name: Compute run metadata - id: meta - env: - EVENT: ${{ github.event_name }} - WF_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - # Target release the report describes and uploads to: - # workflow_run (nightly / vX.Y.Z) -> that release; dispatch -> none (artifact only). - if [ "$EVENT" = "workflow_run" ]; then - RELEASE="$WF_HEAD_BRANCH" - else - RELEASE="" - fi - echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" - if [ "$FULL_SCAN" = "true" ]; then SCAN="full"; else SCAN="baseline"; fi - jq -n \ - --arg target "${RELEASE:-ad-hoc dispatch}" \ - --arg image "$AIO_IMAGE_TAG" \ - --arg persistence "$JANS_PERSISTENCE" \ - --arg scan "$SCAN" \ - --arg commit "$GITHUB_SHA" \ - --arg event "$EVENT" \ - --arg run_url "$RUN_URL" \ - '{target:$target, image:$image, persistence:$persistence, scan:$scan, commit:$commit, event:$event, run_url:$run_url}' \ - > meta.json - cat meta.json - - - name: Build report (incl. PDF) + - name: Finalize report (incl. PDF) run: | pip install --quiet --require-hashes -r .github/workflows/scripts/requirements-pentest.txt + # shellcheck disable=SC2086 python3 .github/workflows/scripts/pentest_report.py \ - --zap dast/zap.json --nuclei dast/nuclei.jsonl \ - --context context.json --analysis analysis.json \ + $PENTEST_INPUTS --context context.json --analysis analysis.json \ --meta meta.json --pdf \ --logo docs/assets/logo/janssen_project_transparent_630px_182px.png \ --prefix pentest-report @@ -251,7 +335,7 @@ jobs: uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - name: Sign and upload report to release - # Runs for tagged releases (dispatch produces the artifact only). + # Runs for nightly / tagged releases (dispatch produces the artifact only). if: steps.meta.outputs.release != '' env: # Publishing to an existing published release needs a PAT; GITHUB_TOKEN @@ -267,18 +351,3 @@ jobs: done # shellcheck disable=SC2086 gh release upload "${RELEASE}" $FILES $BUNDLES --clobber - - - name: Collect target logs - if: always() - run: | - mkdir -p aio-logs - docker logs traefik > aio-logs/traefik.log 2>&1 || true - docker ps -a > aio-logs/containers.txt 2>&1 || true - - - name: Upload target logs - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: pentest-aio-logs - path: aio-logs/ - retention-days: 7 diff --git a/.github/workflows/scripts/pentest_ingest_scans.py b/.github/workflows/scripts/pentest_ingest_scans.py index 6a1a1e4994f..f1036a6f644 100755 --- a/.github/workflows/scripts/pentest_ingest_scans.py +++ b/.github/workflows/scripts/pentest_ingest_scans.py @@ -20,7 +20,8 @@ import sys REPO = os.environ.get("GITHUB_REPOSITORY", "") -TAG = os.environ.get("PENTEST_RELEASE_TAG", "nightly") +# Empty on manual dispatch — no release to pull an SBOM from (see sbom()). +TAG = os.environ.get("PENTEST_RELEASE_TAG", "") OUT = os.environ.get("PENTEST_CONTEXT", "context.json") @@ -59,6 +60,9 @@ def code_scanning_alerts(): def sbom(): + if not TAG: + print("no release tag (e.g. manual dispatch); skipping SBOM ingest", file=sys.stderr) + return {} r = gh("release", "download", TAG, "-R", REPO, "-p", "*sbom*.json", "-D", "_sbom", "--clobber") if r.returncode != 0: diff --git a/.github/workflows/scripts/pentest_report.py b/.github/workflows/scripts/pentest_report.py index a79fb901cf3..6486409392a 100755 --- a/.github/workflows/scripts/pentest_report.py +++ b/.github/workflows/scripts/pentest_report.py @@ -107,9 +107,10 @@ def to_sarif(findings): "shortDescription": {"text": f["name"] or rid}}) level = {"critical": "error", "high": "error", "medium": "warning", "low": "note", "info": "note"}[f["severity"]] + prefix = f"[{f['backend']}] " if f.get("backend") else "" results.append({ "ruleId": rid, "level": level, - "message": {"text": f.get("description") or f["name"] or rid}, + "message": {"text": prefix + (f.get("description") or f["name"] or rid)}, "locations": [{"physicalLocation": {"artifactLocation": { "uri": f.get("url") or f["source"]}}}], }) @@ -186,16 +187,17 @@ def build_pdf(path, findings, counts, meta, ctx, logo): if findings: header = [Paragraph(f"{h}", cell) - for h in ["Severity", "Source", "Finding", "Location"]] + for h in ["Severity", "Backend", "Source", "Finding", "Location"]] rows = [header] for f in findings: rows.append([ Paragraph(escape(f["severity"]), cell), + Paragraph(escape(f.get("backend") or "-"), cell), Paragraph(escape(f["source"]), cell), Paragraph(escape((f["name"] or "")[:160]), cell), Paragraph(escape((f.get("url") or "")[:160]), cell), ]) - ft = Table(rows, colWidths=[20 * mm, 18 * mm, 75 * mm, 65 * mm], repeatRows=1) + ft = Table(rows, colWidths=[18 * mm, 20 * mm, 16 * mm, 68 * mm, 56 * mm], repeatRows=1) style = [ ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cfd8dc")), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#37474f")), @@ -223,6 +225,9 @@ def main(): p = argparse.ArgumentParser() p.add_argument("--zap") p.add_argument("--nuclei") + p.add_argument("--input", nargs=3, action="append", + metavar=("LABEL", "ZAP", "NUCLEI"), + help="a backend's scan outputs; repeatable to consolidate backends") p.add_argument("--context") p.add_argument("--analysis") p.add_argument("--meta") @@ -232,7 +237,20 @@ def main(): p.add_argument("--gate", action="store_true") a = p.parse_args() - findings = load_zap(a.zap) + load_nuclei(a.nuclei) + load_analysis(a.analysis) + def tag(items, backend): + for f in items: + f["backend"] = backend + return items + + findings = [] + if a.input: + for label, zpath, npath in a.input: + findings += tag(load_zap(zpath), label) + findings += tag(load_nuclei(npath), label) + else: + findings += tag(load_zap(a.zap), "") + findings += tag(load_nuclei(a.nuclei), "") + findings += tag(load_analysis(a.analysis), "analysis") findings.sort(key=lambda f: SEV_ORDER[f["severity"]], reverse=True) counts = {s: 0 for s in SEV_ORDER} @@ -272,10 +290,11 @@ def main(): f"code-scanning alerts, {report['ingested_context']['sbom_packages']} " f"SBOM packages.\n\n") if findings: - md.write("| Severity | Source | Finding | URL |\n|---|---|---|---|\n") + md.write("| Severity | Backend | Source | Finding | URL |\n|---|---|---|---|---|\n") for f in findings: - md.write(f"| {md_cell(f['severity'])} | {md_cell(f['source'])} | " - f"{md_cell((f['name'] or '')[:80])} | {md_cell((f.get('url') or '')[:80])} |\n") + md.write(f"| {md_cell(f['severity'])} | {md_cell(f.get('backend') or '-')} | " + f"{md_cell(f['source'])} | {md_cell((f['name'] or '')[:80])} | " + f"{md_cell((f.get('url') or '')[:80])} |\n") else: md.write("No findings.\n") diff --git a/docs/contribute/ci-cd/security-scanning.md b/docs/contribute/ci-cd/security-scanning.md index 36a87bb8f56..aa68e175cfb 100644 --- a/docs/contribute/ci-cd/security-scanning.md +++ b/docs/contribute/ci-cd/security-scanning.md @@ -29,22 +29,30 @@ window (a per-scan shell timeout under a step `timeout-minutes` backstop); if th limit is reached the scan stops and the report is built from partial results. It is **report-only** — it never fails the build. +Two-stage: a `scan` matrix runs the DAST tooling against a fresh AIO for each +persistence backend (`MYSQL`, `PGSQL`) in parallel; a `report` job then produces +**one consolidated report** across both, with a Backend column distinguishing the +findings. + Flow: -1. **Target** — brings up the prebuilt AIO compose stack (the same one - `test-terraform-provider.yml` uses) via `automation/ci/run_aio_for_tf.sh`. +1. **Target (per backend)** — the `scan` matrix brings up the prebuilt AIO compose + stack (the same one `test-terraform-provider.yml` uses) via + `automation/ci/run_aio_for_tf.sh`, once per persistence backend. 2. **Discover** — `scripts/pentest_discover_endpoints.py` reads the OpenID discovery document and known service edges into `targets.json`. -3. **Ingest** — `scripts/pentest_ingest_scans.py` pulls open code-scanning alerts - (CodeQL, Scorecard) and the enriched SBOM from the release into `context.json` - so the scan can correlate against known findings. -4. **DAST** — an open baseline scan (plus an OpenAPI-seeded API scan on - `full_scan`) and a template scan run against every discovered edge. +3. **DAST** — an open baseline scan (plus an OpenAPI-seeded API scan on + `full_scan`) and the full nuclei template set run against every discovered edge; + raw output is uploaded per backend. +4. **Ingest** — the `report` job runs `scripts/pentest_ingest_scans.py` once: it + pulls open code-scanning alerts (CodeQL, Scorecard) and, for a release, the + enriched SBOM into `context.json` for correlation (SBOM ingest is skipped on + manual dispatch, which has no release). 5. **Analysis (optional)** — if `PENTEST_AI_ENDPOINT` / `PENTEST_AI_TOKEN` secrets - are configured, the DAST output and ingested context are sent to a Messages API - endpoint for prioritisation; the model returns a `{ "findings": [...] }` object. - Absent the secrets, the scan is DAST-only. -6. **Report** — `scripts/pentest_report.py` merges everything into + are configured, the consolidated DAST findings and ingested context are sent to + a Messages API endpoint for prioritisation; the model returns a + `{ "findings": [...] }` object. Absent the secrets, the report is DAST-only. +6. **Report** — `scripts/pentest_report.py` merges every backend plus analysis into `pentest-report.{pdf,json,md,sarif}`. The PDF carries the Janssen logo header and a run-metadata block (target release — `nightly` or `vX.Y.Z` — AIO image, persistence, scan type, trigger, commit and run URL) above the severity-ranked diff --git a/docs/contribute/ci-cd/workflows.md b/docs/contribute/ci-cd/workflows.md index 05be52359e0..6f43e30fb1b 100644 --- a/docs/contribute/ci-cd/workflows.md +++ b/docs/contribute/ci-cd/workflows.md @@ -45,7 +45,7 @@ One row per workflow under `.github/workflows/`. See | `scan-sonar.yml` | push/PR, dispatch | SonarCloud quality/security scan per module. | | `scan-scorecard.yml` | push main, weekly | OpenSSF Scorecard. | | `scan-sbom.yml` | tag `v**`/`nightly` | enriched SBOM + compliance reports to release assets. | -| `scan-pentest.yml` | `workflow_run` (Build Docker Images) for nightly/`v**`, dispatch | full DAST pen-test against the live AIO (report-only). | +| `scan-pentest.yml` | `workflow_run` (Build Docker Images) for nightly/`v**`, dispatch | full DAST pen-test against the live AIO for each persistence backend (MYSQL, PGSQL); one consolidated report (report-only). | ## Ops From 64a39c97103a62637c6d5d659c8c0e5492a9c752 Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:18:04 +0300 Subject: [PATCH 09/15] feat(sec): professional-style pentest report + harden report parsing - Report PDF/MD restructured like a professional assessment: cover (logo, CONFIDENTIAL, run metadata, automated-DAST disclaimer), executive summary with a severity-distribution chart, scope & methodology, findings register, detailed write-ups (medium+) with per-finding recommendations, and a metadata appendix; page footer with confidentiality marking. - Loaders read UTF-8 via context managers and validate JSON shapes (non-dict / non-list inputs treated as unavailable instead of crashing); context/meta coerced to dict. - docs: note ad-hoc dispatch as a report target. Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .github/workflows/scripts/pentest_report.py | 414 +++++++++++++++----- docs/contribute/ci-cd/security-scanning.md | 3 +- 2 files changed, 311 insertions(+), 106 deletions(-) diff --git a/.github/workflows/scripts/pentest_report.py b/.github/workflows/scripts/pentest_report.py index 6486409392a..639dc9be8c2 100755 --- a/.github/workflows/scripts/pentest_report.py +++ b/.github/workflows/scripts/pentest_report.py @@ -2,11 +2,13 @@ """Merge DAST + optional analysis output into a consolidated pen-test report. Emits .json, .md, .sarif and (with --pdf) .pdf. -Gating is off by default (report-only); --gate exits non-zero on any high/critical -finding. +The PDF/MD are laid out like a professional assessment report (cover, executive +summary, severity chart, methodology, detailed findings, appendix). Gating is off +by default (report-only); --gate exits non-zero on any high/critical finding. Usage: pentest_report.py [--zap zap.json] [--nuclei nuclei.jsonl] + [--input LABEL zap.json nuclei.jsonl ...] [--context context.json] [--analysis analysis.json] [--meta meta.json] [--pdf] [--logo path.png] [--prefix pentest-report] [--gate] @@ -19,6 +21,38 @@ from xml.sax.saxutils import escape SEV_ORDER = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0} +SEV_LIST = ["critical", "high", "medium", "low", "info"] +SEV_COLOR = {"critical": "#b71c1c", "high": "#e53935", "medium": "#fb8c00", + "low": "#fdd835", "info": "#90a4ae"} +# Severities that get a full written-up section; the rest stay in the summary table. +DETAIL_SEVS = ("critical", "high", "medium") + +# Generic, honest remediation guidance keyed by finding-name substrings. +RECS = [ + ("content security policy", "Set a restrictive Content-Security-Policy header to limit script/style sources."), + ("csp", "Set a restrictive Content-Security-Policy header to limit script/style sources."), + ("strict-transport-security", "Enable HSTS (Strict-Transport-Security) with a long max-age and includeSubDomains."), + ("hsts", "Enable HSTS (Strict-Transport-Security) with a long max-age and includeSubDomains."), + ("x-content-type-options", "Return X-Content-Type-Options: nosniff on all responses."), + ("permissions policy", "Define a Permissions-Policy header disabling unused browser features."), + ("cross-origin", "Set appropriate Cross-Origin-Opener/Embedder/Resource-Policy headers."), + ("cache", "Set Cache-Control: no-store on responses containing sensitive data."), + ("cookie", "Mark cookies Secure, HttpOnly and SameSite as appropriate."), + ("cors", "Restrict CORS to trusted origins; never combine a wildcard origin with credentials."), + ("sql", "Use parameterised queries and strict input validation."), + ("xss", "Contextually encode output, validate input, and apply a strong CSP."), + ("tls", "Enforce modern TLS versions and disable weak ciphers."), + ("server", "Suppress version-revealing Server/X-Powered-By banners."), +] + + +def recommend(f): + n = (f.get("name") or "").lower() + for key, advice in RECS: + if key in n: + return advice + return ("Review the referenced rule and remediate per OWASP ASVS / Web Security " + "Testing Guide guidance; confirm exploitability in context.") def md_cell(s): @@ -31,27 +65,42 @@ def norm_sev(s): return s if s in SEV_ORDER else "info" -def load_zap(path): +def _read_json(path, label): + """Read a JSON file (UTF-8, closed handle). Return the object, or None if the + path is missing/unreadable/truncated — callers treat None as unavailable.""" if not path or not os.path.exists(path): - return [] + return None try: - doc = json.load(open(path)) - except (json.JSONDecodeError, OSError) as e: - # A timeout-killed ZAP can leave a truncated report; treat as no findings - # so the report step still runs. - print(f"warn: ZAP report unreadable ({e}); treating as unavailable", file=sys.stderr) + with open(path, encoding="utf-8") as fh: + return json.load(fh) + except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e: + print(f"warn: {label} report unreadable ({e}); treating as unavailable", file=sys.stderr) + return None + + +def load_zap(path): + doc = _read_json(path, "ZAP") + if not isinstance(doc, dict): return [] out = [] - for site in doc.get("site", []): - for a in site.get("alerts", []): + sites = doc.get("site") + for site in sites if isinstance(sites, list) else []: + if not isinstance(site, dict): + continue + alerts = site.get("alerts") + for a in alerts if isinstance(alerts, list) else []: + if not isinstance(a, dict): + continue risk = {"3": "high", "2": "medium", "1": "low", "0": "info"}.get( str(a.get("riskcode", "0")), "info") + inst = a.get("instances") + uri = inst[0].get("uri") if isinstance(inst, list) and inst and isinstance(inst[0], dict) else None out.append({ "source": "zap", "name": a.get("alert") or a.get("name"), "severity": risk, - "url": (a.get("instances") or [{}])[0].get("uri", site.get("@name")), - "description": (a.get("desc") or "").strip()[:500], + "url": uri or site.get("@name"), + "description": (a.get("desc") or "").strip()[:800], }) return out @@ -60,41 +109,50 @@ def load_nuclei(path): if not path or not os.path.exists(path): return [] out = [] - for line in open(path): - line = line.strip() - if not line: - continue - try: - r = json.loads(line) - except Exception: - continue - info = r.get("info", {}) - out.append({ - "source": "nuclei", - "name": info.get("name") or r.get("template-id"), - "severity": norm_sev(info.get("severity")), - "url": r.get("matched-at") or r.get("host"), - "description": (info.get("description") or "").strip()[:500], - }) + try: + fh = open(path, encoding="utf-8") + except OSError as e: + print(f"warn: nuclei report unreadable ({e}); treating as unavailable", file=sys.stderr) + return [] + with fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + r = json.loads(line) + except json.JSONDecodeError: + continue # tolerate a truncated final line + if not isinstance(r, dict): + continue + info = r.get("info") if isinstance(r.get("info"), dict) else {} + out.append({ + "source": "nuclei", + "name": info.get("name") or r.get("template-id"), + "severity": norm_sev(info.get("severity")), + "url": r.get("matched-at") or r.get("host"), + "description": (info.get("description") or "").strip()[:800], + }) return out def load_analysis(path): - if not path or not os.path.exists(path): + doc = _read_json(path, "analysis") + if not isinstance(doc, dict): return [] - try: - doc = json.load(open(path)) - except (json.JSONDecodeError, OSError) as e: - print(f"warn: analysis report unreadable ({e}); treating as unavailable", file=sys.stderr) + findings = doc.get("findings") + if not isinstance(findings, list): return [] out = [] - for f in doc.get("findings", []): + for f in findings: + if not isinstance(f, dict): + continue out.append({ "source": "analysis", "name": f.get("title") or f.get("name"), "severity": norm_sev(f.get("severity")), "url": f.get("location") or f.get("url"), - "description": (f.get("detail") or f.get("description") or "").strip()[:500], + "description": (f.get("detail") or f.get("description") or "").strip()[:800], }) return out @@ -124,67 +182,143 @@ def to_sarif(findings): } -SEV_COLOR = {"critical": "#b71c1c", "high": "#e53935", "medium": "#fb8c00", - "low": "#fdd835", "info": "#90a4ae"} +def exec_summary(total, counts): + if not total: + return ("The automated assessment completed without recording any findings at the " + "selected severity levels. Absence of findings is not proof of security; see " + "the limitations note below.") + parts = [f"{counts[s]} {s}" for s in SEV_LIST if counts[s]] + lead = "critical or high-severity issues require prompt review" if (counts["critical"] or counts["high"]) \ + else "no critical or high-severity issues were recorded" + return (f"The automated assessment recorded {total} finding(s) " + f"({', '.join(parts)}). At this severity profile, {lead}. Findings are " + f"correlated across the scanned persistence backends and, where configured, " + f"prioritised by an analysis pass. Each medium-and-above item is written up " + f"below with an affected location and remediation guidance.") + + +# ── PDF ─────────────────────────────────────────────────────────────────────── + +def _sev_chart(counts): + from reportlab.graphics.shapes import Drawing, Rect, String + from reportlab.lib import colors + from reportlab.lib.units import mm + rows = SEV_LIST + rowh, barmax, x0 = 9 * mm, 120 * mm, 26 * mm + maxc = max(counts.values()) or 1 + d = Drawing(170 * mm, rowh * len(rows) + 2 * mm) + for i, sev in enumerate(rows): + y = (len(rows) - 1 - i) * rowh + 2 * mm + d.add(String(0, y + 1.5 * mm, sev.title(), fontSize=8, fillColor=colors.black)) + w = (counts[sev] / maxc) * barmax + d.add(Rect(x0, y, max(w, 0.5), 6 * mm, fillColor=colors.HexColor(SEV_COLOR[sev]), strokeColor=None)) + d.add(String(x0 + max(w, 0.5) + 2 * mm, y + 1.5 * mm, str(counts[sev]), fontSize=8, fillColor=colors.black)) + return d def build_pdf(path, findings, counts, meta, ctx, logo): - """Render the report to PDF with a Janssen header. No-op if reportlab absent.""" + """Render a professional-style report to PDF. No-op if reportlab absent.""" try: from reportlab.lib import colors + from reportlab.lib.enums import TA_CENTER from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import mm from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, - Table, TableStyle, Image) + Table, TableStyle, Image, PageBreak, + KeepTogether, HRFlowable) except ImportError: print("warn: reportlab not installed; skipping PDF", file=sys.stderr) return False styles = getSampleStyleSheet() + body = ParagraphStyle("body", parent=styles["BodyText"], fontSize=9.5, leading=13) cell = ParagraphStyle("cell", parent=styles["BodyText"], fontSize=8, leading=10) small = ParagraphStyle("small", parent=styles["BodyText"], fontSize=9, leading=12) + h2 = ParagraphStyle("h2", parent=styles["Heading2"], spaceBefore=6, spaceAfter=4) + center = ParagraphStyle("center", parent=styles["Title"], alignment=TA_CENTER) story = [] + now = datetime.datetime.now(datetime.timezone.utc) + + def chip(sev): + c = SEV_COLOR[sev] + return Paragraph(f' {sev.upper()}', small) + # ── Cover ── if logo and os.path.exists(logo): img = Image(logo) - img._restrictSize(150 * mm, 30 * mm) + img._restrictSize(150 * mm, 34 * mm) + img.hAlign = "CENTER" + story.append(Spacer(1, 30 * mm)) story.append(img) - story.append(Spacer(1, 6 * mm)) - story.append(Paragraph("Penetration Test Report", styles["Title"])) - story.append(Paragraph( - f"Generated {datetime.datetime.now(datetime.timezone.utc):%Y-%m-%d %H:%M UTC}", - styles["Normal"])) - story.append(Spacer(1, 5 * mm)) - - # Run metadata — what this scan ran against. - meta_rows = [ + story.append(Spacer(1, 12 * mm)) + story.append(Paragraph("Penetration Test Report", center)) + story.append(Paragraph("Automated Dynamic Application Security Testing (DAST)", + ParagraphStyle("sub", parent=center, fontSize=12, textColor=colors.HexColor("#546e7a")))) + story.append(Spacer(1, 8 * mm)) + story.append(Paragraph('CONFIDENTIAL', + ParagraphStyle("conf", parent=center, fontSize=12))) + story.append(Spacer(1, 10 * mm)) + cover_rows = [ ["Target release", meta.get("target", "")], ["AIO image", meta.get("image", "")], - ["Persistence", meta.get("persistence", "")], - ["Scan type", meta.get("scan", "")], - ["Trigger", meta.get("event", "")], + ["Persistence backends", meta.get("persistence", "")], + ["Scan profile", meta.get("scan", "")], + ["Generated", f"{now:%Y-%m-%d %H:%M UTC}"], ["Commit", meta.get("commit", "")], - ["Workflow run", meta.get("run_url", "")], - ["Ingested", f"{len(ctx.get('code_scanning_alerts', []))} code-scanning alerts, " - f"{ctx.get('sbom', {}).get('package_count', 0)} SBOM packages"], ] - mt = Table([[Paragraph(f"{escape(k)}", small), Paragraph(escape(str(v)), small)] - for k, v in meta_rows], colWidths=[38 * mm, 140 * mm]) - mt.setStyle(TableStyle([ + ct = Table([[Paragraph(f"{escape(k)}", small), Paragraph(escape(str(v)), small)] + for k, v in cover_rows], colWidths=[45 * mm, 120 * mm], hAlign="CENTER") + ct.setStyle(TableStyle([ ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cfd8dc")), ("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#eceff1")), - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ])) - story.append(mt) + ("VALIGN", (0, 0), (-1, -1), "TOP")])) + story.append(ct) + story.append(Spacer(1, 10 * mm)) + story.append(Paragraph( + "This report is generated automatically by CI using open DAST tooling " + "(OWASP ZAP and Nuclei). It is a point-in-time automated assessment and is " + "not a substitute for a manual penetration test by a qualified assessor.", + ParagraphStyle("disc", parent=small, alignment=TA_CENTER, textColor=colors.HexColor("#607d8b")))) + story.append(PageBreak()) + + # ── Executive summary ── + story.append(Paragraph("1. Executive Summary", h2)) + story.append(Paragraph(exec_summary(len(findings), counts), body)) + story.append(Spacer(1, 4 * mm)) + story.append(Paragraph("Findings by severity", small)) + story.append(_sev_chart(counts)) + story.append(Spacer(1, 3 * mm)) + strip = [[chip(s), Paragraph(str(counts[s]), small)] for s in SEV_LIST] + stab = Table([["Severity", "Count"]] + strip, colWidths=[40 * mm, 20 * mm]) + stab.setStyle(TableStyle([ + ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cfd8dc")), + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#37474f")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white)])) + story.append(stab) story.append(Spacer(1, 5 * mm)) - summary = " | ".join(f"{k}: {counts[k]}" for k in - ["critical", "high", "medium", "low", "info"]) - story.append(Paragraph(f"Findings: {len(findings)}   ({summary})", - styles["Heading3"])) + # ── Scope & methodology ── + story.append(Paragraph("2. Scope & Methodology", h2)) + ingested = (f"{len(ctx.get('code_scanning_alerts', []))} code-scanning alerts and " + f"{ctx.get('sbom', {}).get('package_count', 0)} SBOM packages") + for line in [ + f"Target: Janssen all-in-one ({escape(str(meta.get('image', '')))}), " + f"persistence backends: {escape(str(meta.get('persistence', '')))}.", + "Approach: unauthenticated dynamic testing against the live, " + "traefik-fronted edges discovered from the OpenID configuration and known service paths.", + "Tooling: OWASP ZAP (baseline, and API scan when an OpenAPI spec is present) " + "and Nuclei (full community template set).", + f"Correlation: prior scan output was ingested for context ({ingested}).", + "Limitations: automated, time-bounded, unauthenticated; no manual business-logic " + "testing or exploitation was performed. Findings should be validated before action.", + ]: + story.append(Paragraph(line, body)) + story.append(Spacer(1, 1.5 * mm)) story.append(Spacer(1, 3 * mm)) + # ── Findings register (all) ── + story.append(Paragraph("3. Findings Register", h2)) if findings: header = [Paragraph(f"{h}", cell) for h in ["Severity", "Backend", "Source", "Finding", "Location"]] @@ -202,25 +336,115 @@ def build_pdf(path, findings, counts, meta, ctx, logo): ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cfd8dc")), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#37474f")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ] + ("VALIGN", (0, 0), (-1, -1), "TOP")] for i, f in enumerate(findings, start=1): - style.append(("BACKGROUND", (0, i), (0, i), - colors.HexColor(SEV_COLOR[f["severity"]]))) + style.append(("BACKGROUND", (0, i), (0, i), colors.HexColor(SEV_COLOR[f["severity"]]))) ft.setStyle(TableStyle(style)) story.append(ft) else: - story.append(Paragraph("No findings.", styles["Normal"])) - - doc = SimpleDocTemplate(path, pagesize=A4, - leftMargin=15 * mm, rightMargin=15 * mm, - topMargin=15 * mm, bottomMargin=15 * mm, + story.append(Paragraph("No findings recorded.", body)) + + # ── Detailed findings (medium and above) ── + detailed = [f for f in findings if f["severity"] in DETAIL_SEVS] + if detailed: + story.append(PageBreak()) + story.append(Paragraph("4. Detailed Findings", h2)) + story.append(Paragraph( + "Written up for medium severity and above; low/informational items are listed " + "in the register above and the JSON artifact.", small)) + story.append(Spacer(1, 3 * mm)) + for idx, f in enumerate(detailed, start=1): + block = [ + Paragraph(f'{idx}. {escape(f["name"] or "(unnamed)")}', styles["Heading4"]), + Table([[chip(f["severity"]), + Paragraph(f'Source: {escape(f["source"])}   ' + f'Backend: {escape(f.get("backend") or "-")}', small)]], + colWidths=[35 * mm, 130 * mm]), + Paragraph(f'Affected: {escape(f.get("url") or "n/a")}', small), + Paragraph(f'Description: {escape(f.get("description") or "(none provided)")}', body), + Paragraph(f'Recommendation: {escape(recommend(f))}', body), + HRFlowable(width="100%", thickness=0.4, color=colors.HexColor("#cfd8dc"), + spaceBefore=3, spaceAfter=5), + ] + story.append(KeepTogether(block)) + + # ── Appendix ── + story.append(PageBreak()) + story.append(Paragraph("Appendix A — Run Metadata", h2)) + meta_rows = list(meta.items()) + [ + ("code_scanning_alerts", len(ctx.get("code_scanning_alerts", []))), + ("sbom_packages", ctx.get("sbom", {}).get("package_count", 0)), + ("report_generated", f"{now:%Y-%m-%d %H:%M UTC}"), + ] + at = Table([[Paragraph(f"{escape(str(k))}", small), Paragraph(escape(str(v)), small)] + for k, v in meta_rows], colWidths=[50 * mm, 120 * mm]) + at.setStyle(TableStyle([ + ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cfd8dc")), + ("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#eceff1")), + ("VALIGN", (0, 0), (-1, -1), "TOP")])) + story.append(at) + + def footer(canvas, doc_): + canvas.saveState() + canvas.setFont("Helvetica", 7) + canvas.setFillColor(colors.HexColor("#90a4ae")) + canvas.drawString(15 * mm, 10 * mm, "CONFIDENTIAL — Janssen automated DAST report") + canvas.drawRightString(195 * mm, 10 * mm, f"Page {doc_.page}") + canvas.restoreState() + + doc = SimpleDocTemplate(path, pagesize=A4, leftMargin=15 * mm, rightMargin=15 * mm, + topMargin=15 * mm, bottomMargin=16 * mm, title="Janssen Penetration Test Report") - doc.build(story) + doc.build(story, onFirstPage=footer, onLaterPages=footer) print(f"pdf: {path}") return True +# ── Markdown ────────────────────────────────────────────────────────────────── + +def write_md(path, findings, counts, meta, ctx): + total = len(findings) + with open(path, "w", encoding="utf-8") as md: + md.write("# Penetration Test Report (Automated DAST)\n\n") + md.write("> **CONFIDENTIAL.** Point-in-time automated assessment (OWASP ZAP + Nuclei). " + "Not a substitute for a manual penetration test.\n\n") + if meta: + md.write(f"**Target:** {meta.get('target', '')}   " + f"**Image:** {meta.get('image', '')}   " + f"**Backends:** {meta.get('persistence', '')}   " + f"**Profile:** {meta.get('scan', '')}   " + f"**Commit:** {meta.get('commit', '')}\n\n") + md.write("## 1. Executive Summary\n\n") + md.write(exec_summary(total, counts) + "\n\n") + md.write("| Severity | Count |\n|---|---|\n") + for s in SEV_LIST: + md.write(f"| {s} | {counts[s]} |\n") + md.write(f"\n**Total:** {total}\n\n") + md.write("## 2. Scope & Methodology\n\n") + md.write(f"- **Tooling:** OWASP ZAP (baseline + API scan) and Nuclei (full template set).\n" + f"- **Correlation:** {len(ctx.get('code_scanning_alerts', []))} code-scanning " + f"alerts, {ctx.get('sbom', {}).get('package_count', 0)} SBOM packages ingested.\n" + f"- **Limitations:** automated, time-bounded, unauthenticated; validate before action.\n\n") + md.write("## 3. Findings Register\n\n") + if findings: + md.write("| Severity | Backend | Source | Finding | URL |\n|---|---|---|---|---|\n") + for f in findings: + md.write(f"| {md_cell(f['severity'])} | {md_cell(f.get('backend') or '-')} | " + f"{md_cell(f['source'])} | {md_cell((f['name'] or '')[:80])} | " + f"{md_cell((f.get('url') or '')[:80])} |\n") + else: + md.write("No findings recorded.\n") + detailed = [f for f in findings if f["severity"] in DETAIL_SEVS] + if detailed: + md.write("\n## 4. Detailed Findings (medium and above)\n\n") + for idx, f in enumerate(detailed, start=1): + md.write(f"### {idx}. {f['name'] or '(unnamed)'} — {f['severity'].upper()}\n\n") + md.write(f"- **Source / Backend:** {f['source']} / {f.get('backend') or '-'}\n") + md.write(f"- **Affected:** {f.get('url') or 'n/a'}\n") + md.write(f"- **Description:** {md_cell(f.get('description') or '(none)')}\n") + md.write(f"- **Recommendation:** {recommend(f)}\n\n") + + def main(): p = argparse.ArgumentParser() p.add_argument("--zap") @@ -257,12 +481,10 @@ def tag(items, backend): for f in findings: counts[f["severity"]] += 1 - ctx = {} - if a.context and os.path.exists(a.context): - ctx = json.load(open(a.context)) - meta = {} - if a.meta and os.path.exists(a.meta): - meta = json.load(open(a.meta)) + ctx = _read_json(a.context, "context") + ctx = ctx if isinstance(ctx, dict) else {} + meta = _read_json(a.meta, "meta") + meta = meta if isinstance(meta, dict) else {} report = {"meta": meta, "summary": counts, "total": len(findings), "ingested_context": { @@ -270,33 +492,15 @@ def tag(items, backend): "sbom_packages": ctx.get("sbom", {}).get("package_count", 0)}, "findings": findings} - json.dump(report, open(f"{a.prefix}.json", "w"), indent=2) - json.dump(to_sarif(findings), open(f"{a.prefix}.sarif", "w"), indent=2) + with open(f"{a.prefix}.json", "w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2) + with open(f"{a.prefix}.sarif", "w", encoding="utf-8") as fh: + json.dump(to_sarif(findings), fh, indent=2) if a.pdf: build_pdf(f"{a.prefix}.pdf", findings, counts, meta, ctx, a.logo) - with open(f"{a.prefix}.md", "w") as md: - md.write("# Pen-Test Report (DAST)\n\n") - if meta: - md.write(f"**Target:** {meta.get('target', '')}   " - f"**Image:** {meta.get('image', '')}   " - f"**Persistence:** {meta.get('persistence', '')}   " - f"**Scan:** {meta.get('scan', '')}\n\n") - md.write(f"Total findings: **{len(findings)}** — " - + ", ".join(f"{k}: {counts[k]}" for k in - ["critical", "high", "medium", "low", "info"]) + "\n\n") - md.write(f"Ingested context: {report['ingested_context']['code_scanning_alerts']} " - f"code-scanning alerts, {report['ingested_context']['sbom_packages']} " - f"SBOM packages.\n\n") - if findings: - md.write("| Severity | Backend | Source | Finding | URL |\n|---|---|---|---|---|\n") - for f in findings: - md.write(f"| {md_cell(f['severity'])} | {md_cell(f.get('backend') or '-')} | " - f"{md_cell(f['source'])} | {md_cell((f['name'] or '')[:80])} | " - f"{md_cell((f.get('url') or '')[:80])} |\n") - else: - md.write("No findings.\n") + write_md(f"{a.prefix}.md", findings, counts, meta, ctx) print(f"report: {len(findings)} findings ({counts})") if a.gate and (counts["high"] or counts["critical"]): diff --git a/docs/contribute/ci-cd/security-scanning.md b/docs/contribute/ci-cd/security-scanning.md index aa68e175cfb..2755770aecd 100644 --- a/docs/contribute/ci-cd/security-scanning.md +++ b/docs/contribute/ci-cd/security-scanning.md @@ -54,7 +54,8 @@ Flow: `{ "findings": [...] }` object. Absent the secrets, the report is DAST-only. 6. **Report** — `scripts/pentest_report.py` merges every backend plus analysis into `pentest-report.{pdf,json,md,sarif}`. The PDF carries the Janssen logo header - and a run-metadata block (target release — `nightly` or `vX.Y.Z` — AIO image, + and a run-metadata block (target release — `nightly`, `vX.Y.Z`, or `ad-hoc + dispatch` for manual runs — AIO image, persistence, scan type, trigger, commit and run URL) above the severity-ranked findings table. All formats upload as a workflow artifact; for nightly and tagged-release runs they are also cosign-signed and attached to the corresponding From c1ff319ec85c702bc5d5a9e2a47d080d32398544 Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:03:04 +0300 Subject: [PATCH 10/15] feat(sec): consolidate SAST/SCA/container/SBOM into the pentest report Ingest now pulls every available source: code-scanning alerts (CodeQL + Scorecard), Dependabot advisories, the Trivy container-image CVE report, and the enriched CycloneDX SBOM (component inventory + per-component vulnerabilities). All become findings in the register/counts/detailed write-ups alongside DAST, so one report spans DAST + SAST + SCA + container image + supply-chain. Adds an assessment-coverage table (findings per dimension), an SBOM component-inventory appendix, vendor-severity normalisation, and a detailed-section cap. Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .../workflows/scripts/pentest_ingest_scans.py | 188 ++++++++++++++---- .github/workflows/scripts/pentest_report.py | 187 ++++++++++++++--- docs/contribute/ci-cd/security-scanning.md | 37 ++-- 3 files changed, 334 insertions(+), 78 deletions(-) diff --git a/.github/workflows/scripts/pentest_ingest_scans.py b/.github/workflows/scripts/pentest_ingest_scans.py index f1036a6f644..f5ff00ccc33 100755 --- a/.github/workflows/scripts/pentest_ingest_scans.py +++ b/.github/workflows/scripts/pentest_ingest_scans.py @@ -1,17 +1,20 @@ #!/usr/bin/env python3 -"""Ingest existing scan output into one context file for the pen-test agent. +"""Ingest existing security data into one context file for the consolidated report. -Correlates prior findings so the DAST/analysis stage can prioritise. All sources -are best-effort: a missing source is logged and skipped, never fatal. +Pulls as much relevant data as is available so the report can stand in for a +broad assessment. Every source is best-effort: a missing/failed source is logged +and skipped, never fatal. Sources: - - Code-scanning alerts (CodeQL, Scorecard) via `gh api` - - Enriched SBOM asset from the target release via `gh release` + - Code-scanning alerts (CodeQL SAST + Scorecard) via `gh api` + - Dependabot alerts (known-vulnerable dependencies) via `gh api` + - Trivy container-image CVE report from the release assets + - Enriched SBOM: component inventory + vulnerabilities from the release assets Env: - GH_TOKEN / GITHUB_TOKEN auth for gh (required for code-scanning + release) + GH_TOKEN / GITHUB_TOKEN auth for gh GITHUB_REPOSITORY owner/repo (required) - PENTEST_RELEASE_TAG release holding the SBOM (default nightly) + PENTEST_RELEASE_TAG release holding SBOM/Trivy assets (empty on dispatch) PENTEST_CONTEXT output path (default context.json) """ import json @@ -20,66 +23,167 @@ import sys REPO = os.environ.get("GITHUB_REPOSITORY", "") -# Empty on manual dispatch — no release to pull an SBOM from (see sbom()). TAG = os.environ.get("PENTEST_RELEASE_TAG", "") OUT = os.environ.get("PENTEST_CONTEXT", "context.json") +DL = "_ingest" + +CAP = 5000 # per-source safety cap def gh(*args): try: - return subprocess.run( - ["gh", *args], capture_output=True, text=True, timeout=120 - ) + return subprocess.run(["gh", *args], capture_output=True, text=True, timeout=180) except subprocess.TimeoutExpired: return subprocess.CompletedProcess(args, 124, "", "gh timed out") -def code_scanning_alerts(): - r = gh("api", "-X", "GET", - f"/repos/{REPO}/code-scanning/alerts", - "-f", "state=open", "-f", "per_page=100") +def _api_array(path, *params): + r = gh("api", "--paginate", "-X", "GET", path, *params) if r.returncode != 0: - print(f"warn: code-scanning alerts unavailable: {r.stderr.strip()}", file=sys.stderr) - return [] + print(f"warn: {path} unavailable: {r.stderr.strip()}", file=sys.stderr) + return None try: data = json.loads(r.stdout or "[]") except json.JSONDecodeError as e: - print(f"warn: malformed code-scanning JSON: {e}", file=sys.stderr) + print(f"warn: {path} malformed JSON: {e}", file=sys.stderr) + return None + return data if isinstance(data, list) else [] + + +def code_scanning_alerts(): + data = _api_array(f"/repos/{REPO}/code-scanning/alerts", "-f", "state=open", "-f", "per_page=100") + if data is None: return [] out = [] - for a in data: - rule = a.get("rule", {}) + for a in data[:CAP]: + if not isinstance(a, dict): + continue + rule = a.get("rule", {}) or {} + inst = a.get("most_recent_instance", {}) or {} out.append({ - "tool": a.get("tool", {}).get("name"), + "tool": (a.get("tool", {}) or {}).get("name"), "rule": rule.get("id"), "severity": rule.get("security_severity_level") or rule.get("severity"), - "description": rule.get("description"), - "path": a.get("most_recent_instance", {}).get("location", {}).get("path"), + "description": rule.get("description") or rule.get("name"), + "path": (inst.get("location", {}) or {}).get("path"), + "url": a.get("html_url"), + }) + return out + + +def dependabot_alerts(): + data = _api_array(f"/repos/{REPO}/dependabot/alerts", "-f", "state=open", "-f", "per_page=100") + if data is None: + return [] + out = [] + for a in data[:CAP]: + if not isinstance(a, dict): + continue + dep = ((a.get("dependency", {}) or {}).get("package", {}) or {}) + adv = a.get("security_advisory", {}) or {} + vuln = a.get("security_vulnerability", {}) or {} + out.append({ + "package": dep.get("name"), + "ecosystem": dep.get("ecosystem"), + "severity": adv.get("severity") or vuln.get("severity"), + "ghsa": adv.get("ghsa_id"), + "summary": adv.get("summary"), + "vulnerable_range": (vuln.get("vulnerable_version_range")), + "url": a.get("html_url"), }) return out +def _release_download(patterns): + args = ["release", "download", TAG, "-R", REPO, "-D", DL, "--clobber"] + for p in patterns: + args += ["-p", p] + r = gh(*args) + if r.returncode != 0: + print(f"warn: release download {patterns} failed: {r.stderr.strip()}", file=sys.stderr) + + +def trivy(): + if not TAG: + return {} + _release_download(["trivy-report.json"]) + path = os.path.join(DL, "trivy-report.json") + if not os.path.exists(path): + return {} + try: + doc = json.load(open(path, encoding="utf-8")) + except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e: + print(f"warn: trivy report unreadable: {e}", file=sys.stderr) + return {} + vulns = [] + for res in doc.get("Results", []) or []: + if not isinstance(res, dict): + continue + target = res.get("Target") + for v in res.get("Vulnerabilities", []) or []: + if not isinstance(v, dict): + continue + vulns.append({ + "id": v.get("VulnerabilityID"), + "package": v.get("PkgName"), + "installed": v.get("InstalledVersion"), + "fixed": v.get("FixedVersion"), + "severity": v.get("Severity"), + "title": v.get("Title") or v.get("Description"), + "url": v.get("PrimaryURL"), + "target": target, + }) + return {"vulnerability_count": len(vulns), "vulnerabilities": vulns[:CAP]} + + def sbom(): if not TAG: print("no release tag (e.g. manual dispatch); skipping SBOM ingest", file=sys.stderr) return {} - r = gh("release", "download", TAG, "-R", REPO, - "-p", "*sbom*.json", "-D", "_sbom", "--clobber") - if r.returncode != 0: - print(f"warn: SBOM download failed: {r.stderr.strip()}", file=sys.stderr) + _release_download(["*sbom*.json"]) + if not os.path.isdir(DL): return {} - pkgs = [] - for fn in os.listdir("_sbom"): + pkgs, vulns = [], [] + for fn in os.listdir(DL): + if "sbom" not in fn.lower() or not fn.endswith(".json"): + continue try: - doc = json.load(open(os.path.join("_sbom", fn))) - except Exception: + doc = json.load(open(os.path.join(DL, fn), encoding="utf-8")) + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + continue + if not isinstance(doc, dict): continue - for c in doc.get("components", []) or doc.get("packages", []): + # CycloneDX components / SPDX packages + for c in (doc.get("components") or doc.get("packages") or []): + if not isinstance(c, dict): + continue name = c.get("name") - ver = c.get("version") or c.get("versionInfo") - if name: - pkgs.append({"name": name, "version": ver}) - return {"package_count": len(pkgs), "packages": pkgs[:2000]} + if not name: + continue + lic = c.get("licenses") or c.get("licenseConcluded") + pkgs.append({ + "name": name, + "version": c.get("version") or c.get("versionInfo"), + "purl": c.get("purl"), + "license": json.dumps(lic)[:120] if lic else None, + }) + # CycloneDX top-level vulnerabilities (Parlay enrichment) + for v in (doc.get("vulnerabilities") or []): + if not isinstance(v, dict): + continue + ratings = v.get("ratings") or [] + sev = ratings[0].get("severity") if ratings and isinstance(ratings[0], dict) else None + affects = v.get("affects") or [] + ref = affects[0].get("ref") if affects and isinstance(affects[0], dict) else None + vulns.append({ + "id": v.get("id"), + "severity": sev, + "description": (v.get("description") or "")[:400], + "affects": ref, + "url": (v.get("source", {}) or {}).get("url"), + }) + return {"package_count": len(pkgs), "packages": pkgs[:CAP], + "vulnerability_count": len(vulns), "vulnerabilities": vulns[:CAP]} def main(): @@ -87,13 +191,19 @@ def main(): sys.exit("GITHUB_REPOSITORY not set") ctx = { "repository": REPO, + "release_tag": TAG, "code_scanning_alerts": code_scanning_alerts(), + "dependabot_alerts": dependabot_alerts(), + "trivy": trivy(), "sbom": sbom(), } - with open(OUT, "w") as f: + with open(OUT, "w", encoding="utf-8") as f: json.dump(ctx, f, indent=2) - print(f"ingested {len(ctx['code_scanning_alerts'])} alerts, " - f"{ctx['sbom'].get('package_count', 0)} packages -> {OUT}") + print(f"ingested: {len(ctx['code_scanning_alerts'])} code-scanning, " + f"{len(ctx['dependabot_alerts'])} dependabot, " + f"{ctx['trivy'].get('vulnerability_count', 0)} trivy CVEs, " + f"{ctx['sbom'].get('package_count', 0)} SBOM packages " + f"({ctx['sbom'].get('vulnerability_count', 0)} SBOM vulns) -> {OUT}") if __name__ == "__main__": diff --git a/.github/workflows/scripts/pentest_report.py b/.github/workflows/scripts/pentest_report.py index 639dc9be8c2..d78dffa5406 100755 --- a/.github/workflows/scripts/pentest_report.py +++ b/.github/workflows/scripts/pentest_report.py @@ -24,8 +24,21 @@ SEV_LIST = ["critical", "high", "medium", "low", "info"] SEV_COLOR = {"critical": "#b71c1c", "high": "#e53935", "medium": "#fb8c00", "low": "#fdd835", "info": "#90a4ae"} +# Vendor severity spellings -> our scale. +SEV_ALIAS = {"moderate": "medium", "unknown": "info", "negligible": "info", + "none": "info", "": "info", "warning": "medium", "error": "high", + "note": "low"} # Severities that get a full written-up section; the rest stay in the summary table. DETAIL_SEVS = ("critical", "high", "medium") +# Cap on written-up sections so a CVE-heavy run does not produce an unreadable PDF; +# the full set always remains in the register table and the JSON artifact. +MAX_DETAIL = 200 +# Human labels + which finding sources roll up into each assessment dimension. +SOURCE_DIMENSION = { + "zap": "DAST (web/API)", "nuclei": "DAST (web/API)", + "code-scan": "SAST (source)", "dependabot": "SCA (dependencies)", + "sbom": "SCA (dependencies)", "trivy": "Container image", "analysis": "Analysis", +} # Generic, honest remediation guidance keyed by finding-name substrings. RECS = [ @@ -62,9 +75,43 @@ def md_cell(s): def norm_sev(s): s = (s or "info").lower() + s = SEV_ALIAS.get(s, s) return s if s in SEV_ORDER else "info" +def context_findings(ctx): + """Turn ingested SAST / SCA / container / SBOM data into findings so they + appear in the register, counts and detailed write-ups alongside DAST.""" + out = [] + for a in ctx.get("code_scanning_alerts", []) or []: + out.append({"source": "code-scan", "backend": "-", + "name": a.get("rule") or a.get("description") or "code-scanning alert", + "severity": norm_sev(a.get("severity")), + "url": a.get("path") or a.get("url"), + "description": (a.get("description") or "")[:800]}) + for a in ctx.get("dependabot_alerts", []) or []: + pkg = a.get("package") or "dependency" + out.append({"source": "dependabot", "backend": "-", + "name": f"{pkg} — {a.get('ghsa') or 'advisory'}", + "severity": norm_sev(a.get("severity")), + "url": a.get("url") or a.get("vulnerable_range"), + "description": (a.get("summary") or "")[:800]}) + for v in (ctx.get("trivy", {}) or {}).get("vulnerabilities", []) or []: + label = f"{v.get('id')} — {v.get('package')} {v.get('installed') or ''}".strip() + out.append({"source": "trivy", "backend": "-", + "name": label, + "severity": norm_sev(v.get("severity")), + "url": v.get("url") or v.get("target"), + "description": (v.get("title") or "")[:800]}) + for v in (ctx.get("sbom", {}) or {}).get("vulnerabilities", []) or []: + out.append({"source": "sbom", "backend": "-", + "name": (f"{v.get('id')} — {v.get('affects') or ''}").strip(" —"), + "severity": norm_sev(v.get("severity")), + "url": v.get("url"), + "description": (v.get("description") or "")[:800]}) + return out + + def _read_json(path, label): """Read a JSON file (UTF-8, closed handle). Return the object, or None if the path is missing/unreadable/truncated — callers treat None as unavailable.""" @@ -182,6 +229,18 @@ def to_sarif(findings): } +def coverage_rows(findings): + """Findings grouped by assessment dimension, for a coverage table.""" + dims = {} + for f in findings: + d = SOURCE_DIMENSION.get(f["source"], f["source"]) + dims[d] = dims.get(d, 0) + 1 + order = ["DAST (web/API)", "SAST (source)", "SCA (dependencies)", "Container image", "Analysis"] + rows = [(d, dims[d]) for d in order if d in dims] + rows += [(d, c) for d, c in dims.items() if d not in order] + return rows + + def exec_summary(total, counts): if not total: return ("The automated assessment completed without recording any findings at the " @@ -300,21 +359,41 @@ def chip(sev): # ── Scope & methodology ── story.append(Paragraph("2. Scope & Methodology", h2)) - ingested = (f"{len(ctx.get('code_scanning_alerts', []))} code-scanning alerts and " - f"{ctx.get('sbom', {}).get('package_count', 0)} SBOM packages") + sb = ctx.get("sbom", {}) or {} + tv = ctx.get("trivy", {}) or {} + ingested = (f"{len(ctx.get('code_scanning_alerts', []))} code-scanning alerts, " + f"{len(ctx.get('dependabot_alerts', []))} Dependabot alerts, " + f"{tv.get('vulnerability_count', 0)} container CVEs (Trivy), and an SBOM of " + f"{sb.get('package_count', 0)} components ({sb.get('vulnerability_count', 0)} with advisories)") for line in [ f"Target: Janssen all-in-one ({escape(str(meta.get('image', '')))}), " f"persistence backends: {escape(str(meta.get('persistence', '')))}.", - "Approach: unauthenticated dynamic testing against the live, " - "traefik-fronted edges discovered from the OpenID configuration and known service paths.", - "Tooling: OWASP ZAP (baseline, and API scan when an OpenAPI spec is present) " - "and Nuclei (full community template set).", - f"Correlation: prior scan output was ingested for context ({ingested}).", - "Limitations: automated, time-bounded, unauthenticated; no manual business-logic " - "testing or exploitation was performed. Findings should be validated before action.", + "Approach: a consolidated assessment combining dynamic testing of the live " + "service with static analysis, dependency (SCA) and container-image data.", + "Dynamic (DAST): OWASP ZAP (baseline, plus API scan when an OpenAPI spec is " + "present) and Nuclei (full community template set) against the traefik-fronted edges " + "discovered from the OpenID configuration and known service paths.", + "Static & supply-chain: CodeQL and Scorecard (code-scanning), Dependabot " + "advisories, Trivy container-image CVEs, and the released CycloneDX SBOM.", + f"Ingested data: {ingested}.", + "Limitations: automated and time-bounded; no manual business-logic testing or " + "exploitation was performed. Findings — especially SCA/CVE matches — should be " + "validated for reachability and exploitability before action.", ]: story.append(Paragraph(line, body)) story.append(Spacer(1, 1.5 * mm)) + story.append(Spacer(1, 2 * mm)) + + cov = coverage_rows(findings) + if cov: + story.append(Paragraph("Assessment coverage (findings by dimension)", small)) + covt = Table([["Dimension", "Findings"]] + [[d, str(c)] for d, c in cov], + colWidths=[70 * mm, 25 * mm]) + covt.setStyle(TableStyle([ + ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cfd8dc")), + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#37474f")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white)])) + story.append(covt) story.append(Spacer(1, 3 * mm)) # ── Findings register (all) ── @@ -345,13 +424,17 @@ def chip(sev): story.append(Paragraph("No findings recorded.", body)) # ── Detailed findings (medium and above) ── - detailed = [f for f in findings if f["severity"] in DETAIL_SEVS] + detailed_all = [f for f in findings if f["severity"] in DETAIL_SEVS] + detailed = detailed_all[:MAX_DETAIL] if detailed: story.append(PageBreak()) story.append(Paragraph("4. Detailed Findings", h2)) - story.append(Paragraph( - "Written up for medium severity and above; low/informational items are listed " - "in the register above and the JSON artifact.", small)) + note = ("Written up for medium severity and above; low/informational items are listed " + "in the register above and the JSON artifact.") + if len(detailed_all) > len(detailed): + note += (f" Showing the top {len(detailed)} of {len(detailed_all)} medium+ findings; " + "the full set is in the JSON/SARIF artifacts.") + story.append(Paragraph(note, small)) story.append(Spacer(1, 3 * mm)) for idx, f in enumerate(detailed, start=1): block = [ @@ -384,6 +467,29 @@ def chip(sev): ("VALIGN", (0, 0), (-1, -1), "TOP")])) story.append(at) + pkgs = (ctx.get("sbom", {}) or {}).get("packages", []) or [] + if pkgs: + story.append(Spacer(1, 6 * mm)) + story.append(Paragraph("Appendix B — Component Inventory (SBOM)", h2)) + shown = pkgs[:80] + story.append(Paragraph( + f"{len(pkgs)} components in the released SBOM; showing {len(shown)}. " + "Full inventory in the SBOM release asset.", small)) + story.append(Spacer(1, 2 * mm)) + inv = [[Paragraph("Component", cell), Paragraph("Version", cell), + Paragraph("License", cell)]] + for c in shown: + inv.append([Paragraph(escape(str(c.get("name") or "")[:70]), cell), + Paragraph(escape(str(c.get("version") or "")[:30]), cell), + Paragraph(escape(str(c.get("license") or "")[:40]), cell)]) + it = Table(inv, colWidths=[95 * mm, 40 * mm, 35 * mm], repeatRows=1) + it.setStyle(TableStyle([ + ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cfd8dc")), + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#37474f")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("VALIGN", (0, 0), (-1, -1), "TOP")])) + story.append(it) + def footer(canvas, doc_): canvas.saveState() canvas.setFont("Helvetica", 7) @@ -420,11 +526,25 @@ def write_md(path, findings, counts, meta, ctx): for s in SEV_LIST: md.write(f"| {s} | {counts[s]} |\n") md.write(f"\n**Total:** {total}\n\n") + sb = ctx.get("sbom", {}) or {} + tv = ctx.get("trivy", {}) or {} md.write("## 2. Scope & Methodology\n\n") - md.write(f"- **Tooling:** OWASP ZAP (baseline + API scan) and Nuclei (full template set).\n" - f"- **Correlation:** {len(ctx.get('code_scanning_alerts', []))} code-scanning " - f"alerts, {ctx.get('sbom', {}).get('package_count', 0)} SBOM packages ingested.\n" - f"- **Limitations:** automated, time-bounded, unauthenticated; validate before action.\n\n") + md.write("- **Dynamic (DAST):** OWASP ZAP (baseline + API scan) and Nuclei (full template set).\n" + "- **Static & supply-chain:** CodeQL + Scorecard (code-scanning), Dependabot " + "advisories, Trivy container CVEs, and the released CycloneDX SBOM.\n" + f"- **Ingested:** {len(ctx.get('code_scanning_alerts', []))} code-scanning, " + f"{len(ctx.get('dependabot_alerts', []))} Dependabot, " + f"{tv.get('vulnerability_count', 0)} Trivy CVEs, " + f"{sb.get('package_count', 0)} SBOM components " + f"({sb.get('vulnerability_count', 0)} with advisories).\n" + "- **Limitations:** automated, time-bounded; validate reachability/exploitability " + "before action.\n\n") + cov = coverage_rows(findings) + if cov: + md.write("**Assessment coverage**\n\n| Dimension | Findings |\n|---|---|\n") + for d, c in cov: + md.write(f"| {d} | {c} |\n") + md.write("\n") md.write("## 3. Findings Register\n\n") if findings: md.write("| Severity | Backend | Source | Finding | URL |\n|---|---|---|---|---|\n") @@ -434,15 +554,29 @@ def write_md(path, findings, counts, meta, ctx): f"{md_cell((f.get('url') or '')[:80])} |\n") else: md.write("No findings recorded.\n") - detailed = [f for f in findings if f["severity"] in DETAIL_SEVS] + detailed_all = [f for f in findings if f["severity"] in DETAIL_SEVS] + detailed = detailed_all[:MAX_DETAIL] if detailed: md.write("\n## 4. Detailed Findings (medium and above)\n\n") + if len(detailed_all) > len(detailed): + md.write(f"_Showing the top {len(detailed)} of {len(detailed_all)} medium+ " + "findings; full set in the JSON/SARIF artifacts._\n\n") for idx, f in enumerate(detailed, start=1): md.write(f"### {idx}. {f['name'] or '(unnamed)'} — {f['severity'].upper()}\n\n") md.write(f"- **Source / Backend:** {f['source']} / {f.get('backend') or '-'}\n") md.write(f"- **Affected:** {f.get('url') or 'n/a'}\n") md.write(f"- **Description:** {md_cell(f.get('description') or '(none)')}\n") md.write(f"- **Recommendation:** {recommend(f)}\n\n") + pkgs = sb.get("packages", []) or [] + if pkgs: + md.write("## Appendix — Component Inventory (SBOM)\n\n") + md.write(f"{len(pkgs)} components; showing up to 50.\n\n") + md.write("| Component | Version | License |\n|---|---|---|\n") + for c in pkgs[:50]: + md.write(f"| {md_cell(str(c.get('name') or ''))} | " + f"{md_cell(str(c.get('version') or ''))} | " + f"{md_cell(str(c.get('license') or ''))} |\n") + md.write("\n") def main(): @@ -466,6 +600,11 @@ def tag(items, backend): f["backend"] = backend return items + ctx = _read_json(a.context, "context") + ctx = ctx if isinstance(ctx, dict) else {} + meta = _read_json(a.meta, "meta") + meta = meta if isinstance(meta, dict) else {} + findings = [] if a.input: for label, zpath, npath in a.input: @@ -475,21 +614,21 @@ def tag(items, backend): findings += tag(load_zap(a.zap), "") findings += tag(load_nuclei(a.nuclei), "") findings += tag(load_analysis(a.analysis), "analysis") + # SAST / SCA / container / SBOM data ingested into context become findings too. + findings += context_findings(ctx) findings.sort(key=lambda f: SEV_ORDER[f["severity"]], reverse=True) counts = {s: 0 for s in SEV_ORDER} for f in findings: counts[f["severity"]] += 1 - ctx = _read_json(a.context, "context") - ctx = ctx if isinstance(ctx, dict) else {} - meta = _read_json(a.meta, "meta") - meta = meta if isinstance(meta, dict) else {} - report = {"meta": meta, "summary": counts, "total": len(findings), "ingested_context": { "code_scanning_alerts": len(ctx.get("code_scanning_alerts", [])), - "sbom_packages": ctx.get("sbom", {}).get("package_count", 0)}, + "dependabot_alerts": len(ctx.get("dependabot_alerts", [])), + "trivy_vulnerabilities": (ctx.get("trivy", {}) or {}).get("vulnerability_count", 0), + "sbom_packages": (ctx.get("sbom", {}) or {}).get("package_count", 0), + "sbom_vulnerabilities": (ctx.get("sbom", {}) or {}).get("vulnerability_count", 0)}, "findings": findings} with open(f"{a.prefix}.json", "w", encoding="utf-8") as fh: diff --git a/docs/contribute/ci-cd/security-scanning.md b/docs/contribute/ci-cd/security-scanning.md index 2755770aecd..64be1e20fe6 100644 --- a/docs/contribute/ci-cd/security-scanning.md +++ b/docs/contribute/ci-cd/security-scanning.md @@ -44,22 +44,29 @@ Flow: 3. **DAST** — an open baseline scan (plus an OpenAPI-seeded API scan on `full_scan`) and the full nuclei template set run against every discovered edge; raw output is uploaded per backend. -4. **Ingest** — the `report` job runs `scripts/pentest_ingest_scans.py` once: it - pulls open code-scanning alerts (CodeQL, Scorecard) and, for a release, the - enriched SBOM into `context.json` for correlation (SBOM ingest is skipped on - manual dispatch, which has no release). +4. **Ingest** — the `report` job runs `scripts/pentest_ingest_scans.py` once, + pulling every available data source into `context.json`: open code-scanning + alerts (CodeQL SAST + Scorecard), Dependabot advisories, the Trivy container-image + CVE report, and the enriched CycloneDX SBOM (component inventory + per-component + vulnerabilities). Each source is best-effort; release assets (Trivy, SBOM) are + skipped on manual dispatch, which has no release. These become findings in the + report alongside DAST, so one document covers DAST + SAST + SCA + container image + + supply-chain. 5. **Analysis (optional)** — if `PENTEST_AI_ENDPOINT` / `PENTEST_AI_TOKEN` secrets - are configured, the consolidated DAST findings and ingested context are sent to - a Messages API endpoint for prioritisation; the model returns a - `{ "findings": [...] }` object. Absent the secrets, the report is DAST-only. -6. **Report** — `scripts/pentest_report.py` merges every backend plus analysis into - `pentest-report.{pdf,json,md,sarif}`. The PDF carries the Janssen logo header - and a run-metadata block (target release — `nightly`, `vX.Y.Z`, or `ad-hoc - dispatch` for manual runs — AIO image, - persistence, scan type, trigger, commit and run URL) above the severity-ranked - findings table. All formats upload as a workflow artifact; for nightly and - tagged-release runs they are also cosign-signed and attached to the corresponding - release. Manual `workflow_dispatch` runs produce the artifact only. + are configured, the consolidated findings (all dimensions) and ingested context + are sent to a Messages API endpoint for prioritisation; the model returns a + `{ "findings": [...] }` object. Absent the secrets, the report keeps the + tool-derived findings only. +6. **Report** — `scripts/pentest_report.py` merges every backend and every ingested + source into `pentest-report.{pdf,json,md,sarif}`, laid out like a professional + assessment: cover (logo, CONFIDENTIAL, run metadata, automated-DAST disclaimer), + executive summary with a severity chart, scope/methodology with an + assessment-coverage table (findings per dimension), a findings register (with a + Backend column), detailed write-ups with recommendations for medium+ findings, + and appendices for run metadata and the SBOM component inventory. All formats + upload as a workflow artifact; for nightly and tagged-release runs they are also + cosign-signed and attached to the corresponding release. Manual + `workflow_dispatch` runs produce the artifact only. ### Configuration From fbe47efb5bd90ef91394968a5fa308e7d9df1333 Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:09:21 +0300 Subject: [PATCH 11/15] fix(ci): wait for SBOM+Trivy release assets before pentest ingest On release runs the SBOM (scan-sbom.yml) and Trivy report (build-docker-images) publish to the release from separate workflows that may still be running when the pentest starts. Poll the release for both assets (bounded, best-effort) before ingest so the consolidated report has complete supply-chain data; manual dispatch skips the wait. Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .github/workflows/scan-pentest.yml | 23 ++++++++++++++++++++++ docs/contribute/ci-cd/security-scanning.md | 5 +++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scan-pentest.yml b/.github/workflows/scan-pentest.yml index 9307ca2e342..ff4441c4695 100644 --- a/.github/workflows/scan-pentest.yml +++ b/.github/workflows/scan-pentest.yml @@ -212,6 +212,29 @@ jobs: pattern: pentest-raw-* path: raw + - name: Wait for release scan assets + # Release runs only: the SBOM (scan-sbom.yml) and Trivy report + # (build-docker-images) publish to the release from other workflows that may + # still be running. Wait so ingest sees complete supply-chain data. Bounded + # and best-effort — ingest is resilient if an asset never appears. + if: github.event_name == 'workflow_run' + env: + GH_TOKEN: ${{ github.token }} + RELEASE: ${{ github.event.workflow_run.head_branch }} + run: | + for i in $(seq 1 60); do + assets=$(gh release view "$RELEASE" -R "$GITHUB_REPOSITORY" \ + --json assets --jq '[.assets[].name]' 2>/dev/null || echo '[]') + have_sbom=$(echo "$assets" | jq 'any(.[]; test("sbom"; "i"))') + have_trivy=$(echo "$assets" | jq 'any(.[]; test("trivy-report.json"))') + if [ "$have_sbom" = "true" ] && [ "$have_trivy" = "true" ]; then + echo "release assets present (sbom + trivy)"; exit 0 + fi + echo "waiting for release assets (sbom=$have_sbom trivy=$have_trivy), attempt ${i}/60..." + sleep 30 + done + echo "::warning::timed out waiting for release assets; ingesting whatever is present" + - name: Ingest existing scan results env: GH_TOKEN: ${{ github.token }} diff --git a/docs/contribute/ci-cd/security-scanning.md b/docs/contribute/ci-cd/security-scanning.md index 64be1e20fe6..9aea2d3a736 100644 --- a/docs/contribute/ci-cd/security-scanning.md +++ b/docs/contribute/ci-cd/security-scanning.md @@ -48,8 +48,9 @@ Flow: pulling every available data source into `context.json`: open code-scanning alerts (CodeQL SAST + Scorecard), Dependabot advisories, the Trivy container-image CVE report, and the enriched CycloneDX SBOM (component inventory + per-component - vulnerabilities). Each source is best-effort; release assets (Trivy, SBOM) are - skipped on manual dispatch, which has no release. These become findings in the + vulnerabilities). On a release run it first waits (bounded, best-effort) for the + SBOM and Trivy assets to be published by their own workflows so the data is + complete; manual dispatch skips the wait and is best-effort. These become findings in the report alongside DAST, so one document covers DAST + SAST + SCA + container image + supply-chain. 5. **Analysis (optional)** — if `PENTEST_AI_ENDPOINT` / `PENTEST_AI_TOKEN` secrets From baaf203176bb5850f6b71bf5817f80e449ee9e56 Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:17:20 +0300 Subject: [PATCH 12/15] fix(sec): keep pentest report confidential (artifact-only) + hard-fail on missing release data - Remove the release upload + cosign signing: the report is CONFIDENTIAL and must not be attached to the public release. It is uploaded only as a workflow artifact (repo-access-controlled). Drops id-token/MOAUTO usage from the report job. - Release runs now HARD-FAIL if the SBOM/Trivy release assets never publish within the wait window, so a release report is never silently incomplete; manual dispatch stays best-effort. Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .github/workflows/scan-pentest.yml | 43 +++++++--------------- docs/contribute/ci-cd/security-scanning.md | 20 +++++----- 2 files changed, 24 insertions(+), 39 deletions(-) diff --git a/.github/workflows/scan-pentest.yml b/.github/workflows/scan-pentest.yml index ff4441c4695..3e6064a43aa 100644 --- a/.github/workflows/scan-pentest.yml +++ b/.github/workflows/scan-pentest.yml @@ -3,10 +3,12 @@ # # The `scan` job runs the DAST tooling (ZAP baseline + optional API scan + full # nuclei template set) against a fresh AIO for each persistence backend (MYSQL, -# PGSQL) in parallel, uploading raw scanner output. The `report` job ingests -# existing scan output (code-scanning alerts + SBOM) for correlation, optionally -# forwards results to a pluggable analysis endpoint, and publishes ONE -# consolidated report (PDF/JSON/MD/SARIF). Report-only: it never fails the build. +# PGSQL) in parallel, uploading raw scanner output. The `report` job ingests every +# available source (code-scanning alerts, Dependabot, Trivy, SBOM), optionally +# forwards results to a pluggable analysis endpoint, and produces ONE consolidated +# CONFIDENTIAL report (PDF/JSON/MD/SARIF) uploaded as a workflow artifact only — +# never attached to the public release. It does not fail on findings, but a release +# run HARD-FAILS if the required upstream release assets (SBOM/Trivy) never publish. name: "Scan: Pen Test (DAST)" @@ -188,7 +190,6 @@ jobs: permissions: contents: read security-events: read # pentest_ingest_scans.py lists code-scanning alerts via github.token - id-token: write # keyless cosign sign-blob for the release report bundles env: FULL_SCAN: ${{ github.event.inputs.full_scan || 'false' }} PENTEST_AI_ENDPOINT: ${{ secrets.PENTEST_AI_ENDPOINT }} @@ -215,8 +216,8 @@ jobs: - name: Wait for release scan assets # Release runs only: the SBOM (scan-sbom.yml) and Trivy report # (build-docker-images) publish to the release from other workflows that may - # still be running. Wait so ingest sees complete supply-chain data. Bounded - # and best-effort — ingest is resilient if an asset never appears. + # still be running. A release report must be complete, so wait for both and + # HARD-FAIL if they do not appear (manual dispatch skips this and is best-effort). if: github.event_name == 'workflow_run' env: GH_TOKEN: ${{ github.token }} @@ -233,7 +234,8 @@ jobs: echo "waiting for release assets (sbom=$have_sbom trivy=$have_trivy), attempt ${i}/60..." sleep 30 done - echo "::warning::timed out waiting for release assets; ingesting whatever is present" + echo "::error::required release assets (SBOM/Trivy) not published within the wait window" >&2 + exit 1 - name: Ingest existing scan results env: @@ -341,6 +343,9 @@ jobs: --prefix pentest-report cat pentest-report.md >> "$GITHUB_STEP_SUMMARY" + # Artifact only — the report is CONFIDENTIAL and must NOT be attached to the + # public release. GitHub Actions artifacts are restricted to users with repo + # access; that is the report's only distribution channel. - name: Upload report artifact if: always() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 @@ -352,25 +357,3 @@ jobs: pentest-report.md pentest-report.sarif retention-days: 30 - - - name: Install Cosign - if: steps.meta.outputs.release != '' - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - - - name: Sign and upload report to release - # Runs for nightly / tagged releases (dispatch produces the artifact only). - if: steps.meta.outputs.release != '' - env: - # Publishing to an existing published release needs a PAT; GITHUB_TOKEN - # cannot update published vX.Y.Z releases. - GH_TOKEN: ${{ secrets.MOAUTO_WORKFLOW_TOKEN }} - RELEASE: ${{ steps.meta.outputs.release }} - run: | - FILES="pentest-report.pdf pentest-report.json pentest-report.md pentest-report.sarif" - BUNDLES="" - for f in $FILES; do - cosign sign-blob --yes --bundle "${f}.bundle" "${f}" - BUNDLES="${BUNDLES} ${f}.bundle" - done - # shellcheck disable=SC2086 - gh release upload "${RELEASE}" $FILES $BUNDLES --clobber diff --git a/docs/contribute/ci-cd/security-scanning.md b/docs/contribute/ci-cd/security-scanning.md index 9aea2d3a736..2d886966863 100644 --- a/docs/contribute/ci-cd/security-scanning.md +++ b/docs/contribute/ci-cd/security-scanning.md @@ -18,7 +18,7 @@ output, and where results land, then describes the pen-test that correlates them | Sonar | `scan-sonar.yml` | quality + security hotspots per module | Sonar report | SonarCloud (off-platform) | | Scorecard | `scan-scorecard.yml` | supply-chain posture | SARIF | code scanning + artifact + OpenSSF | | SBOM (Parlay + sbomqs) | `scan-sbom.yml` | dependency graph + compliance | signed JSON | release assets | -| Pen-test (DAST) | `scan-pentest.yml` | live endpoints | PDF/JSON/MD/SARIF | artifact; cosign-signed to the nightly & tagged (`vX.Y.Z`) releases via `MOAUTO_WORKFLOW_TOKEN` | +| Pen-test (DAST) | `scan-pentest.yml` | live endpoints | PDF/JSON/MD/SARIF | workflow artifact only — CONFIDENTIAL, not attached to the public release | ## Pen-test (DAST) @@ -27,7 +27,9 @@ tagged (`v**`) release — so it scans the freshly published all-in-one image on manual dispatch. It runs the full DAST template set within a bounded time window (a per-scan shell timeout under a step `timeout-minutes` backstop); if the limit is reached the scan stops and the report is built from partial results. It -is **report-only** — it never fails the build. +It does **not fail on findings** (severity never breaks the build), but a release +run **hard-fails** if the required upstream release assets (SBOM/Trivy) never +publish, so a release report is never silently incomplete. Two-stage: a `scan` matrix runs the DAST tooling against a fresh AIO for each persistence backend (`MYSQL`, `PGSQL`) in parallel; a `report` job then produces @@ -48,9 +50,10 @@ Flow: pulling every available data source into `context.json`: open code-scanning alerts (CodeQL SAST + Scorecard), Dependabot advisories, the Trivy container-image CVE report, and the enriched CycloneDX SBOM (component inventory + per-component - vulnerabilities). On a release run it first waits (bounded, best-effort) for the - SBOM and Trivy assets to be published by their own workflows so the data is - complete; manual dispatch skips the wait and is best-effort. These become findings in the + vulnerabilities). On a release run it first waits for the SBOM and Trivy assets + to be published by their own workflows and hard-fails if they never appear, so a + release report is always complete; manual dispatch skips the wait and is + best-effort. These become findings in the report alongside DAST, so one document covers DAST + SAST + SCA + container image + supply-chain. 5. **Analysis (optional)** — if `PENTEST_AI_ENDPOINT` / `PENTEST_AI_TOKEN` secrets @@ -65,9 +68,9 @@ Flow: assessment-coverage table (findings per dimension), a findings register (with a Backend column), detailed write-ups with recommendations for medium+ findings, and appendices for run metadata and the SBOM component inventory. All formats - upload as a workflow artifact; for nightly and tagged-release runs they are also - cosign-signed and attached to the corresponding release. Manual - `workflow_dispatch` runs produce the artifact only. + are uploaded **only** as a workflow artifact (30-day retention) — the report is + CONFIDENTIAL and is never attached to the public release; GitHub Actions + artifacts restrict access to users with repository access. ### Configuration @@ -77,7 +80,6 @@ Flow: | `PENTEST_AI_TOKEN` (secret) | optional | API key, sent as the `x-api-key` header | | `PENTEST_AI_MODEL` (var) | optional | model id (default `claude-opus-5`) | | `PENTEST_AI_API_VERSION` (var) | optional | `anthropic-version` header (default `2023-06-01`) | -| `MOAUTO_WORKFLOW_TOKEN` (secret) | for tag runs | upload the signed report to the published release | The analysis step calls the Messages API directly: it sends the DAST output and ingested context as a single user message with a system prompt instructing the From 0ac6c1e35ea38f0186ca2431cc334074bce1381b Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:21:29 +0300 Subject: [PATCH 13/15] feat(sec): post pentest PDF + run link to Zulip pen-test topic Upload the PDF via Zulip user_uploads then post a message linking it plus the workflow run URL to #bot_reporter under its own 'pen-test' topic (mirrors the integration-test reporter). Best-effort; skipped if no PDF or ZULIP_API_KEY. Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .github/workflows/scan-pentest.yml | 28 ++++++++++++++++++++++ docs/contribute/ci-cd/security-scanning.md | 5 +++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/scan-pentest.yml b/.github/workflows/scan-pentest.yml index 3e6064a43aa..73450381cdd 100644 --- a/.github/workflows/scan-pentest.yml +++ b/.github/workflows/scan-pentest.yml @@ -357,3 +357,31 @@ jobs: pentest-report.md pentest-report.sarif retention-days: 30 + + # Post the PDF + a link to this run to Zulip #bot_reporter under its own + # "pen-test" topic. The send-message action cannot attach files, so upload the + # PDF via the user_uploads API first, then link it. Best-effort. + - name: Report to Zulip + if: always() + continue-on-error: true + env: + ZULIP_API_KEY: ${{ secrets.ZULIP_API_KEY }} + ZULIP_EMAIL: "git-gluu@gluu.org" + ZULIP_SITE: "https://chat.gluu.org" + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if [ ! -f pentest-report.pdf ]; then echo "no PDF produced; skipping Zulip"; exit 0; fi + if [ -z "${ZULIP_API_KEY}" ]; then echo "ZULIP_API_KEY unset; skipping Zulip"; exit 0; fi + target=$(jq -r '.target // "run"' meta.json 2>/dev/null || echo run) + uri=$(curl -sS -u "${ZULIP_EMAIL}:${ZULIP_API_KEY}" \ + -X POST "${ZULIP_SITE}/api/v1/user_uploads" \ + -F "filename=@pentest-report.pdf" | jq -r '.uri // empty') + if [ -z "${uri}" ]; then echo "PDF upload to Zulip failed; skipping"; exit 0; fi + content=$(printf '**Pen-Test Report — %s**\n[pentest-report.pdf](%s)\n[Workflow run](%s)' \ + "${target}" "${uri}" "${RUN_URL}") + curl -sS -u "${ZULIP_EMAIL}:${ZULIP_API_KEY}" \ + -X POST "${ZULIP_SITE}/api/v1/messages" \ + -d "type=stream" \ + --data-urlencode "to=bot_reporter" \ + --data-urlencode "topic=pen-test" \ + --data-urlencode "content=${content}" diff --git a/docs/contribute/ci-cd/security-scanning.md b/docs/contribute/ci-cd/security-scanning.md index 2d886966863..dd4dd36fa93 100644 --- a/docs/contribute/ci-cd/security-scanning.md +++ b/docs/contribute/ci-cd/security-scanning.md @@ -70,7 +70,9 @@ Flow: and appendices for run metadata and the SBOM component inventory. All formats are uploaded **only** as a workflow artifact (30-day retention) — the report is CONFIDENTIAL and is never attached to the public release; GitHub Actions - artifacts restrict access to users with repository access. + artifacts restrict access to users with repository access. The PDF and a link to + the run are also posted to Zulip (`#bot_reporter`, topic `pen-test`) when + `ZULIP_API_KEY` is set. ### Configuration @@ -80,6 +82,7 @@ Flow: | `PENTEST_AI_TOKEN` (secret) | optional | API key, sent as the `x-api-key` header | | `PENTEST_AI_MODEL` (var) | optional | model id (default `claude-opus-5`) | | `PENTEST_AI_API_VERSION` (var) | optional | `anthropic-version` header (default `2023-06-01`) | +| `ZULIP_API_KEY` (secret) | optional | post the PDF + run link to Zulip `#bot_reporter` / `pen-test`; skipped if unset | The analysis step calls the Messages API directly: it sends the DAST output and ingested context as a single user message with a system prompt instructing the From f4b714cd0b685ac7e305c47149120fe833b6d980 Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:48:45 +0300 Subject: [PATCH 14/15] feat(sec): API-scan all in-repo OpenAPI specs in the pentest Previously the ZAP API scan was gated behind full_scan and used only one live-discovered spec, so the documented API surface (auth-server, config-api, fido2, scim, lock) was never actively scanned. Now every in-repo OpenAPI spec is active-scanned against the live instance (-O), always (release/nightly), one scan per service. Report consolidates all ZAP outputs per backend (baseline + per-spec API) via --input LABEL DIR globbing zap*.json. Drops the full_scan input. Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .github/workflows/scan-pentest.yml | 55 +++++++++++++-------- .github/workflows/scripts/pentest_report.py | 15 +++--- docs/contribute/ci-cd/security-scanning.md | 7 +-- 3 files changed, 47 insertions(+), 30 deletions(-) diff --git a/.github/workflows/scan-pentest.yml b/.github/workflows/scan-pentest.yml index 73450381cdd..9cc9b63b31f 100644 --- a/.github/workflows/scan-pentest.yml +++ b/.github/workflows/scan-pentest.yml @@ -1,8 +1,9 @@ # Nightly and tagged-release DAST pen-test against a live all-in-one instance. # Chained after "Build Docker Images" so it scans the freshly published image. # -# The `scan` job runs the DAST tooling (ZAP baseline + optional API scan + full -# nuclei template set) against a fresh AIO for each persistence backend (MYSQL, +# The `scan` job runs the DAST tooling (ZAP baseline + ZAP API scans of every +# in-repo OpenAPI spec + full nuclei template set) against a fresh AIO for each +# persistence backend (MYSQL, # PGSQL) in parallel, uploading raw scanner output. The `report` job ingests every # available source (code-scanning alerts, Dependabot, Trivy, SBOM), optionally # forwards results to a pluggable analysis endpoint, and produces ONE consolidated @@ -24,10 +25,6 @@ on: description: "AIO image to scan" required: false default: "ghcr.io/janssenproject/jans/all-in-one:0.0.0-nightly" - full_scan: - description: "Run the full active scan (slower) instead of baseline only" - type: boolean - default: false permissions: contents: read @@ -62,7 +59,6 @@ jobs: JANS_FQDN: pentest-${{ github.run_id }}-${{ matrix.persistence }}.jans.test JANS_PERSISTENCE: ${{ matrix.persistence }} AIO_IMAGE_TAG: ${{ github.event.inputs.aio_image_tag || 'ghcr.io/janssenproject/jans/all-in-one:0.0.0-nightly' }} - FULL_SCAN: ${{ github.event.inputs.full_scan || 'false' }} steps: - name: Harden Runner uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 @@ -127,18 +123,34 @@ jobs: ghcr.io/zaproxy/zaproxy:stable \ zap-baseline.py -t "$JANS_URL" -J zap.json -I || true - - name: DAST — API scan (OpenAPI-seeded, full only) - if: env.FULL_SCAN == 'true' - timeout-minutes: 65 + - name: DAST — API scans (in-repo OpenAPI specs) + timeout-minutes: 240 + env: + JANS_URL: https://${{ env.JANS_FQDN }} run: | - spec=$(jq -r '.openapi_specs[0] // empty' targets.json) - if [ -n "$spec" ]; then - timeout 3600 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD/dast:/zap/wrk:rw" \ + # Active-scan the documented API surface using the authoritative in-repo + # OpenAPI specs (complete, unlike endpoints guessed from the live server), + # targeting the live instance via -O. One scan per service; each bounded so + # a huge spec cannot stall the run, partial results still feed the report. + specs="jans-auth-server/docs/swagger.yaml + jans-config-api/docs/jans-config-api-swagger.yaml + jans-fido2/docs/jansFido2Swagger.yaml + jans-scim/server/src/main/resources/jans-scim-openapi.yaml + jans-lock/lock-server.yaml" + i=0 + echo "$specs" | while read -r s; do + [ -n "$s" ] || continue + if [ ! -f "$s" ]; then echo "spec missing, skipping: $s"; continue; fi + i=$((i + 1)) + name=$(basename "$s" | tr -c 'A-Za-z0-9._-' '_') + cp "$s" "dast/spec-${i}-${name}" + echo "API scan ($s)" + timeout 2400 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD/dast:/zap/wrk:rw" \ ghcr.io/zaproxy/zaproxy:stable \ - zap-api-scan.py -t "$spec" -f openapi -J zap-api.json -I || true - else - echo "no OpenAPI spec discovered; skipping API scan" - fi + zap-api-scan.py -t "/zap/wrk/spec-${i}-${name}" -f openapi \ + -O "$JANS_URL" -J "zap-api-${i}.json" -I \ + || echo "api scan ended (timeout/non-zero) for ${s}; continuing" + done - name: DAST — template scan timeout-minutes: 300 # backstop; the shell timeout below fires first @@ -191,7 +203,6 @@ jobs: contents: read security-events: read # pentest_ingest_scans.py lists code-scanning alerts via github.token env: - FULL_SCAN: ${{ github.event.inputs.full_scan || 'false' }} PENTEST_AI_ENDPOINT: ${{ secrets.PENTEST_AI_ENDPOINT }} PENTEST_AI_TOKEN: ${{ secrets.PENTEST_AI_TOKEN }} PENTEST_AI_MODEL: ${{ vars.PENTEST_AI_MODEL || 'claude-opus-5' }} @@ -266,15 +277,17 @@ jobs: else IMAGE="${repo}:0.0.0-nightly"; fi COMMIT="${WF_HEAD_SHA:-$GITHUB_SHA}" - if [ "$FULL_SCAN" = "true" ]; then SCAN="full"; else SCAN="baseline"; fi + # Every run does baseline + API (all in-repo specs) + full nuclei. + SCAN="baseline+api+nuclei" - # Consolidated inputs: one --input per backend that produced output. + # Consolidated inputs: one --input per backend dir that produced output; + # the report globs all zap*.json (baseline + per-spec API) + nuclei.jsonl. INPUTS="" PERS="" for b in MYSQL PGSQL; do d="raw/pentest-raw-$b" if [ -d "$d" ]; then - INPUTS="$INPUTS --input $b $d/zap.json $d/nuclei.jsonl" + INPUTS="$INPUTS --input $b $d" PERS="${PERS:+$PERS,}$b" fi done diff --git a/.github/workflows/scripts/pentest_report.py b/.github/workflows/scripts/pentest_report.py index d78dffa5406..167bbae1444 100755 --- a/.github/workflows/scripts/pentest_report.py +++ b/.github/workflows/scripts/pentest_report.py @@ -15,6 +15,7 @@ """ import argparse import datetime +import glob import json import os import sys @@ -583,9 +584,9 @@ def main(): p = argparse.ArgumentParser() p.add_argument("--zap") p.add_argument("--nuclei") - p.add_argument("--input", nargs=3, action="append", - metavar=("LABEL", "ZAP", "NUCLEI"), - help="a backend's scan outputs; repeatable to consolidate backends") + p.add_argument("--input", nargs=2, action="append", + metavar=("LABEL", "DIR"), + help="a backend's output dir (zap*.json + nuclei.jsonl); repeatable") p.add_argument("--context") p.add_argument("--analysis") p.add_argument("--meta") @@ -607,9 +608,11 @@ def tag(items, backend): findings = [] if a.input: - for label, zpath, npath in a.input: - findings += tag(load_zap(zpath), label) - findings += tag(load_nuclei(npath), label) + for label, d in a.input: + # every ZAP output in the dir: baseline (zap.json) + per-spec API scans (zap-api-*.json) + for zf in sorted(glob.glob(os.path.join(d, "zap*.json"))): + findings += tag(load_zap(zf), label) + findings += tag(load_nuclei(os.path.join(d, "nuclei.jsonl")), label) else: findings += tag(load_zap(a.zap), "") findings += tag(load_nuclei(a.nuclei), "") diff --git a/docs/contribute/ci-cd/security-scanning.md b/docs/contribute/ci-cd/security-scanning.md index dd4dd36fa93..dd15f044461 100644 --- a/docs/contribute/ci-cd/security-scanning.md +++ b/docs/contribute/ci-cd/security-scanning.md @@ -43,9 +43,10 @@ Flow: `automation/ci/run_aio_for_tf.sh`, once per persistence backend. 2. **Discover** — `scripts/pentest_discover_endpoints.py` reads the OpenID discovery document and known service edges into `targets.json`. -3. **DAST** — an open baseline scan (plus an OpenAPI-seeded API scan on - `full_scan`) and the full nuclei template set run against every discovered edge; - raw output is uploaded per backend. +3. **DAST** — a ZAP baseline scan, ZAP active API scans of every in-repo OpenAPI + spec (auth-server, config-api, fido2, scim, lock) targeting the live instance, + and the full nuclei template set against every discovered edge; raw output + (baseline + per-spec API + nuclei) is uploaded per backend. 4. **Ingest** — the `report` job runs `scripts/pentest_ingest_scans.py` once, pulling every available data source into `context.json`: open code-scanning alerts (CodeQL SAST + Scorecard), Dependabot advisories, the Trivy container-image From cbe510a7e416833d50bfd4508fbfb44fa5255c53 Mon Sep 17 00:00:00 2001 From: moauto <54212639+mo-auto@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:36:46 +0300 Subject: [PATCH 15/15] fix(ci): pre-clone nuclei templates so the template scan actually runs nuclei's built-in installer fetches templates via the GitHub release API (unauthenticated in CI) and intermittently returns nothing -> 'no templates provided for scan', so 0 templates executed. Clone nuclei-templates on the runner and mount it read-only via -t; add -duc since templates are now supplied. Kept outside dast/ so the raw artifact stays small. Signed-off-by: moauto <54212639+mo-auto@users.noreply.github.com> --- .github/workflows/scan-pentest.yml | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/workflows/scan-pentest.yml b/.github/workflows/scan-pentest.yml index 9cc9b63b31f..8b122750e79 100644 --- a/.github/workflows/scan-pentest.yml +++ b/.github/workflows/scan-pentest.yml @@ -155,20 +155,24 @@ jobs: - name: DAST — template scan timeout-minutes: 300 # backstop; the shell timeout below fires first run: | - # Run the FULL template set within a bounded window. The image ships no - # templates, so nuclei installs them on first run (do NOT pass -duc, which - # skips that install). interactsh stays enabled for OOB (SSRF/RCE) coverage. - # -no-mhe keeps scanning a hardened IDP that resets many probes (default - # max-host-error would skip the host). -timeout/-retries bound per-request - # waits. Wrapped in `timeout` (< the step backstop) so a true hang still - # yields partial results and a green step. - # Pinned to an immutable digest (v3.11.1); the template set still downloads - # fresh at runtime, so coverage stays current while the engine is reproducible. - timeout 17400 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD/dast:/data:rw" \ + # Pre-fetch the template set on the runner via a plain git clone and mount + # it read-only. nuclei's own installer pulls via the GitHub release API, + # which is unauthenticated here and intermittently rate-limited/empty + # (observed: "no templates provided for scan"). The clone is reliable and + # keeps coverage current. Kept OUTSIDE dast/ so it is not in the raw artifact. + git clone --depth 1 https://github.com/projectdiscovery/nuclei-templates.git \ + "$PWD/nuclei-templates" || echo "template clone failed; nuclei will fall back to its own fetch" + # Full set within a bounded window. interactsh stays enabled for OOB + # (SSRF/RCE) coverage; -no-mhe keeps scanning a hardened IDP that resets + # many probes; -duc is safe now that templates are supplied via -t. Wrapped + # in `timeout` (< the step backstop) so a hang still yields partial results. + # Engine pinned to an immutable digest (v3.11.1). + timeout 17400 docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" \ + -v "$PWD/dast:/data:rw" -v "$PWD/nuclei-templates:/nt:ro" \ projectdiscovery/nuclei@sha256:582d5546902e67052097cb2d07296c642d50a1afc5e44623cb038845df9a32eb \ - -l /data/targets.txt -jsonl -o /data/nuclei.jsonl \ + -t /nt -l /data/targets.txt -jsonl -o /data/nuclei.jsonl \ -severity low,medium,high,critical \ - -timeout 10 -retries 1 -no-mhe \ + -timeout 10 -retries 1 -no-mhe -duc \ || echo "nuclei ended (timeout or non-zero exit); using partial results" - name: Upload raw scan output