Skip to content

feat(vuln-scanner): add ZAP + Nuclei DAST scanning of MIT Learn QA - #5632

Open
shaidar wants to merge 5 commits into
mainfrom
feat/vuln-scanner-dast
Open

feat(vuln-scanner): add ZAP + Nuclei DAST scanning of MIT Learn QA#5632
shaidar wants to merge 5 commits into
mainfrom
feat/vuln-scanner-dast

Conversation

@shaidar

@shaidar shaidar commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

What are the relevant tickets?

N/A

Description (What does it do?)

Adds a new vuln_scanner Pulumi project (src/ol_infrastructure/applications/vuln_scanner/) that runs OWASP ZAP and Nuclei as weekly Kubernetes CronJobs against MIT Learn's QA endpoint (api.rc.learn.mit.edu), on the operations cluster's QA stack.

Why these two tools and this scope: this AWS account already runs Security Hub (CIS + AWS Foundational Security Best Practices standards), Amazon Inspector (ECR image CVE scanning), and GuardDuty (threat detection) -- confirmed via live AWS CLI checks before any of this was built, specifically to avoid duplicating existing coverage. None of that tests live application behavior (XSS, SQLi, auth/business-logic bugs), which is the specific gap this closes. OpenVAS and a broader Trivy rollout were considered and intentionally dropped for the same reason (rationale documented inline in __main__.py).

Key design points:

  • ZAP uses its Automation Framework openapi job against MIT Learn's confirmed OpenAPI schema (/api/v1/schema/) for endpoint discovery, since the default spider finds almost nothing against a bare JSON API.
  • Each scan's raw report is uploaded to S3, and findings are converted to AWS Security Finding Format (ASFF) and imported into Security Hub via BatchImportFindings, so results land in the same triage surface as existing GuardDuty/Inspector/Config findings instead of a report nobody checks.
  • A diff-and-archive step (BatchUpdateFindings) clears findings that no longer reproduce -- ASFF findings never auto-resolve on their own.
  • Nuclei matches that reference a known CVE get tagged with the more specific .../Vulnerabilities/CVE ASFF type and populate the Vulnerabilities field, so they show up in Security Hub's CVE-specific views.
  • Defaults to ZAP's passive baseline scan (no active attack payloads) until a first run is reviewed; flips to full via one config value.
  • Single-environment (QA, not Production) by design, matching this tool's active-scanning risk profile and its target.

Screenshots (if appropriate):

N/A

How can this be tested?

  1. pulumi preview on the new vuln_scanner project (requires a real pulumi stack init QA --secrets-provider=awskms://alias/infrastructure-secrets-qa first -- not yet done, see checklist).
  2. After pulumi up: kubectl get cronjobs -n vuln-scanner.
  3. Manually trigger a run instead of waiting for the schedule: kubectl create job --from=cronjob/zap-mitlearn-qa zap-manual-test -n vuln-scanner (and the nuclei-mitlearn-qa equivalent), then kubectl logs the job.
  4. Confirm the raw report lands in the ol-vuln-scanner-reports-qa S3 bucket, and confirm the findings appear in Security Hub:
    aws securityhub get-findings --filters '{"GeneratorId":[{"Value":"zap-automation-framework/mitlearn-qa","Comparison":"EQUALS"}]}'
  5. Run the CronJob twice and confirm the second run updates the same finding Ids in Security Hub rather than duplicating them; fix/suppress one and confirm a third run archives it.
  6. Confirm a run that finds real alerts still shows Succeeded (not Failed) via kubectl get jobs -n vuln-scanner -- validates the initContainer exit-code handling.

Locally validated: the full repo pre-commit suite (ruff, mypy, secret detection, yamllint, hadolint) passes on this branch. The reporter's ASFF-building logic was also exercised directly against realistic ZAP/Nuclei report fixtures (not just read through) to confirm the exact BatchImportFindings payload shape, cross-checked against AWS's own ASFF/BatchImportFindings documentation.

Additional Context

This went through multiple rounds of review (automated code review plus manual verification against AWS's ASFF/BatchImportFindings documentation) that caught and fixed several real bugs before this PR, including: an unbounded cron schedule that would overflow past 6 targets, third-party ZAP/Nuclei images relying on unverified non-root defaults, an initContainer exit-code design that could silently drop reports when ZAP found real vulnerabilities, an archive-on-zero-findings bug that could wipe out previously-tracked findings on a silent scan failure, and a CreatedAt bug that would have reset a finding's age on every weekly re-scan.

Opened as a draft since the checklist below has real pre-enable items outstanding.

Checklist:

  • Run pulumi stack init QA for real -- done, real secretsprovider/encryptedkey are in this PR now

  • Pin ghcr.io/zaproxy/zaproxy and projectdiscovery/nuclei to specific digests before enabling the CronJob schedules

  • Fly the updated simple-pulumi-meta pipeline (cd src/ol_concourse/pipelines/infrastructure/simple_pulumi/ && python meta.py && fly -t pr-inf sp -p simple-pulumi-meta -c definition.json) so the new pulumi-vuln-scanner pipeline (image build + QA deploy, preview-gated) actually gets created -- the config is in this PR and verified (python pipeline.py vuln-scanner generates correctly, and the Dockerfile builds under the exact CONTEXT/DOCKERFILE paths Concourse will use), but nothing runs until this is fly'd

  • Verify Nuclei's CLI flags against the pinned version -- found -td was never a real flag and would have failed at container start; fixed (see commits)

  • Confirm with whoever owns MIT Learn QA's APISIX route config whether chaitin-waf/rate-limiting plugins are active, and coordinate before flipping zap_scan_type to full

  • New finding from a real pulumi preview on operations.QA (the EKS stack this PR adds the vuln-scanner namespace to): that stack has two pending changes already drifted from main, unrelated to this PR -- a Traefik chart version bump and an AWS provider region-field rename. Applying the namespace requires a pulumi up on that shared stack, which would apply those too. Decide whether to apply everything together or land the Traefik/provider drift separately first, then run pulumi up on operations.QA before this PR'''s own pulumi up can succeed (the namespace has to actually exist first).

shaidar and others added 5 commits August 28, 2026 09:53
Adds a new vuln_scanner Pulumi project deploying OWASP ZAP and Nuclei as
weekly Kubernetes CronJobs against MIT Learn's QA endpoint, on the
operations cluster. Findings are uploaded to S3 and imported into AWS
Security Hub via ASFF, with a diff-and-archive step so fixed findings
actually clear instead of sitting ACTIVE forever, and CVE-specific typing
for Nuclei matches that reference a known CVE.

No existing AWS-native tooling (Security Hub/Inspector/GuardDuty, all
confirmed enabled in this account) tests live application behavior --
this closes that specific gap rather than duplicating what's already
running.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… reporter

The reporter's Dockerfile was single-stage FROM mitodl/ol-python-base,
which has a shell and a package manager -- it didn't actually match
gwarek's stated runtime posture ("ships no shell") the way the code
comments claimed.

Switched to a multi-stage build: ol-python-base for the build stage,
gcr.io/distroless/base-debian13 (matching Debian release, verified by
hand -- a mismatched pair fails at container start with a glibc
symbol-version error, not a build-time error) for a genuinely shell-less
final stage, with the built CPython interpreter and venv copied across.
Verified end-to-end with real docker build/run: boto3 imports and
reporter.py's own entrypoint both work, /bin/sh is confirmed absent, and
the image shrank from 839MB to 147MB.

Also bumped to Python 3.14 (matching the newest precedent already in
this repo for standalone tool images -- release_bot and
kubewatch_webhook_handler both use python:3.14-slim) rather than 3.12,
which nothing here actually requires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pinned ghcr.io/zaproxy/zaproxy and projectdiscovery/nuclei to real
digests captured via `docker buildx imagetools inspect`, replacing the
floating :stable/:latest tags.

While verifying Nuclei's CLI against the actual pinned image (v3.11.1),
found that -td was never a real flag -- it would have failed at
container start. Also found that -t/-update-template-dir don't compose
the way pointing both at the same custom directory suggests: doing that
made Nuclei decide templates "weren't installed" there and auto-install
a second, nested copy, so the scan would have silently run a smaller,
different template set than the one -update-templates had just written.

Fixed by relying on $HOME alone (already set for this container) --
verified end-to-end with real `docker run`, including an actual scan
against example.com: templates install to $HOME/nuclei-templates and
the scan step finds them with no extra flags, no nested-install surprise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires vuln-scanner into the simple_pulumi meta-pipeline (the same
mechanism release_bot uses for its own image build + deploy), so the
reporter image gets built and pushed to ECR and the QA stack gets
deployed automatically via a reviewed preview gate, rather than needing
a one-off manual pipeline.

Registered in three required places (each backed by a test that fails
if forgotten): production_app_names in meta.py, PROJECT_VERSIONS in
versions_map.py, and PROJECT_SECRETS in secrets_map.py (both empty --
this project reads no version-pin constants or SOPS secrets yet).

topology="preview-gated", same reasoning as release-bot: don't
auto-deploy a tool that runs active attack payloads and holds real
S3-write/Security-Hub credentials on every push.

The build job's CONTEXT is always the whole pulumi_project_path
directory, not the Dockerfile's own directory (confirmed by reading
_build_docker_image_job, not assumed) -- since the reporter's Dockerfile
lives in a reporter/ subdirectory rather than at the project root like
release_bot's, its COPY sources needed to change from bare filenames to
reporter/-prefixed paths. Verified by actually running
`python pipeline.py vuln-scanner` (confirms the generated CONTEXT/
DOCKERFILE values) and a real `docker build -f reporter/Dockerfile .`
from the vuln_scanner directory (confirms the image still builds and
runs correctly under that same context).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ran pulumi stack init --secrets-provider=awskms://alias/infrastructure-secrets-qa QA
for real, against live credentials. Adds the real secretsprovider/
encryptedkey Pulumi's own config writer generated, merged into the
existing config file rather than replacing it -- verified via diff
before committing that only those two keys were added.

Also ran `pulumi preview` for real against this stack and against the
operations.QA EKS stack (to check the eks:namespaces addition from an
earlier commit): vuln_scanner's own preview now fails cleanly on "the
vuln-scanner namespace doesn't exist yet" rather than any code error,
confirming the Pulumi program itself is correct. Applying that
namespace (a `pulumi up` on the EKS stack) is being held off since that
stack's preview also surfaces two unrelated pending changes already
drifted from main (a Traefik chart version bump, an AWS provider
region-field rename) that would apply alongside it -- a deployment
sequencing decision, not a code change, so left for a separate `up`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@shaidar
shaidar marked this pull request as ready for review August 28, 2026 17:58
Copilot AI balanced review requested due to automatic review settings August 28, 2026 17:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds weekly ZAP and Nuclei DAST scanning for MIT Learn QA, with S3 report storage and Security Hub finding synchronization.

Changes:

  • Adds the vulnerability-scanner Pulumi application and Kubernetes CronJobs.
  • Adds an ASFF reporter image and AWS finding lifecycle logic.
  • Integrates deployment into the simple Pulumi pipeline.

Reviewed changes

Copilot reviewed 14 out of 17 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
pyproject.toml Adds lint exceptions for scanner builders.
src/ol_infrastructure/lib/ol_types.py Registers the vulnerability-scanner service.
src/ol_infrastructure/infrastructure/aws/eks/Pulumi.operations.QA.yaml Adds the scanner namespace.
src/ol_infrastructure/applications/vuln_scanner/__init__.py Adds the application package.
src/ol_infrastructure/applications/vuln_scanner/__main__.py Defines scanner infrastructure and CronJobs.
src/ol_infrastructure/applications/vuln_scanner/Pulumi.yaml Defines the Pulumi project.
src/ol_infrastructure/applications/vuln_scanner/Pulumi.QA.yaml Configures the QA target and stack encryption.
src/ol_infrastructure/applications/vuln_scanner/vuln_scanner_policy.hcl Adds the placeholder Vault policy.
src/ol_infrastructure/applications/vuln_scanner/reporter/__init__.py Adds the reporter package.
src/ol_infrastructure/applications/vuln_scanner/reporter/Dockerfile Builds the distroless reporter image.
src/ol_infrastructure/applications/vuln_scanner/reporter/pyproject.toml Declares reporter dependencies.
src/ol_infrastructure/applications/vuln_scanner/reporter/uv.lock Locks reporter dependencies.
src/ol_infrastructure/applications/vuln_scanner/reporter/reporter.py Converts and synchronizes scan findings.
src/ol_concourse/pipelines/versions_map.py Registers scanner version dependencies.
src/ol_concourse/pipelines/secrets_map.py Registers scanner secret dependencies.
src/ol_concourse/pipelines/infrastructure/simple_pulumi/pipeline.py Adds the build and gated deployment pipeline.
src/ol_concourse/pipelines/infrastructure/simple_pulumi/meta.py Registers the generated pipeline.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

# No shell to chain commands through, and no CMD -- ENTRYPOINT calls the
# venv's own interpreter directly, mirroring the same constraint gwarek's
# migration job already works around (`__main__.py:723-726`).
ENTRYPOINT ["/opt/venv/bin/python3", "reporter.py"]
Comment on lines +350 to +354
f"nuclei -update-templates; "
f"nuclei -target {target_url} "
f"-jsonl -output {NUCLEI_REPORT_PATH}; "
f'echo "nuclei exited $?"; '
f"test -f {NUCLEI_REPORT_PATH}"
Comment on lines +399 to +403
for i in range(0, len(identifiers), 100):
securityhub_client.batch_update_findings(
FindingIdentifiers=identifiers[i : i + 100],
RecordState="ARCHIVED",
)
application_labels = {**k8s_global_labels.model_dump(), "app": "vuln-scanner"}

targets = vuln_scanner_config.require_object("targets")
zap_scan_type = vuln_scanner_config.get("zap_scan_type") or "baseline"
Comment on lines +351 to +352
f"nuclei -target {target_url} "
f"-jsonl -output {NUCLEI_REPORT_PATH}; "
Comment on lines +2 to +7
# NOTE: secretsprovider/encryptedkey are intentionally omitted here -- they
# are minted by a real `pulumi stack init QA --secrets-provider=awskms://...`
# run against live AWS/Pulumi credentials, which this authoring session does
# not have. Whoever runs that stack init for the first time should end up
# with those two keys added here by the Pulumi CLI itself; do not hand-write
# a fake encryptedkey value.
return existing


def archive_stale_findings(
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants