Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 69 additions & 29 deletions .github/workflows/scan-pentest.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Nightly + 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
Expand All @@ -10,12 +11,11 @@
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**"
# 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:
Expand All @@ -31,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'))))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down Expand Up @@ -67,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:<version>, 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}"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Bring up AIO target
run: bash automation/ci/run_aio_for_tf.sh

Expand All @@ -76,42 +103,59 @@ 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
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
timeout-minutes: 30
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 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: 65
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 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
Comment thread
moabu marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
else
echo "no OpenAPI spec discovered; skipping API scan"
fi

- name: DAST — template scan
timeout-minutes: 300 # backstop; the shell timeout below fires first
run: |
docker run --rm --network host --add-host "${JANS_FQDN}:127.0.0.1" -v "$PWD:/data:rw" \
projectdiscovery/nuclei:latest \
# 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
# -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" \
projectdiscovery/nuclei@sha256:582d5546902e67052097cb2d07296c642d50a1afc5e44623cb038845df9a32eb \
-l /data/targets.txt -jsonl -o /data/nuclei.jsonl \
-severity low,medium,high,critical || true
-severity low,medium,high,critical \
-timeout 10 -retries 1 -no-mhe \
|| echo "nuclei ended (timeout or non-zero exit); using partial results"

- name: Analysis (Messages API)
if: env.PENTEST_AI_ENDPOINT != ''
Expand All @@ -122,8 +166,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,
Expand Down Expand Up @@ -155,17 +199,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; scheduled nightly -> the nightly
# release; ad-hoc dispatch -> none (artifact only).
if [ "$REF_TYPE" = "tag" ]; then
RELEASE="$REF_NAME"
elif [ "$EVENT" = "schedule" ]; then
RELEASE="nightly"
# workflow_run (nightly / vX.Y.Z) -> that release; dispatch -> none (artifact only).
if [ "$EVENT" = "workflow_run" ]; then
RELEASE="$WF_HEAD_BRANCH"
Comment thread
moabu marked this conversation as resolved.
Outdated
else
RELEASE=""
fi
Expand All @@ -187,7 +227,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 \
Expand All @@ -211,7 +251,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
Expand Down
14 changes: 12 additions & 2 deletions .github/workflows/scripts/pentest_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
Comment thread
coderabbitai[bot] marked this conversation as resolved.
out = []
for site in doc.get("site", []):
for a in site.get("alerts", []):
Expand Down Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion docs/contribute/ci-cd/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
```

Expand All @@ -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 |

Expand Down
17 changes: 10 additions & 7 deletions docs/contribute/ci-cd/security-scanning.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ output, and where results land, then describes the pen-test that correlates them

## 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 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 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:

Expand All @@ -42,12 +46,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,
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 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.
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

Expand Down
2 changes: 1 addition & 1 deletion docs/contribute/ci-cd/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | `workflow_run` (Build Docker Images) for nightly/`v**`, dispatch | full DAST pen-test against the live AIO (report-only). |

## Ops

Expand Down