Skip to content

fix(egress): pin resolved public IP to close SSRF DNS-rebinding TOCTOU (S-1) #864

fix(egress): pin resolved public IP to close SSRF DNS-rebinding TOCTOU (S-1)

fix(egress): pin resolved public IP to close SSRF DNS-rebinding TOCTOU (S-1) #864

Workflow file for this run

name: Security Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1"
permissions:
contents: read
concurrency:
group: security-${{ github.ref }}
cancel-in-progress: true
jobs:
bandit:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"
- name: Install Bandit
run: python -m pip install --upgrade pip bandit
- name: Run Bandit
run: |
bandit -r src sdk --ini .bandit --severity-level medium -f json -o /tmp/bandit-current.json || true
python scripts/bandit_diff.py .bandit-baseline.json /tmp/bandit-current.json
safety:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"
- name: Resolve Safety dependency inputs
run: |
python - <<'PY'
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import tomllib
root = Path(".")
output_dir = root / ".tmp-security"
output_dir.mkdir(exist_ok=True)
def load_project_dependencies(pyproject_path: Path) -> list[str]:
if not pyproject_path.exists():
return []
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
return list(data.get("project", {}).get("dependencies", []))
def load_requirements(requirements_path: Path) -> list[str]:
if not requirements_path.exists():
return []
requirements: list[str] = []
for line in requirements_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
requirements.append(stripped)
return requirements
def write_requirements(target: Path, entries: list[str]) -> None:
deduped = list(dict.fromkeys(entries))
target.write_text("\n".join(deduped) + "\n", encoding="utf-8")
def resolve_requirements(name: str, entries: list[str], target: Path) -> int:
temp_input = output_dir / f"{name}.in"
write_requirements(temp_input, entries)
with tempfile.TemporaryDirectory(prefix=f"safety-{name}-", dir=output_dir) as temp_dir:
venv_dir = Path(temp_dir) / "venv"
subprocess.run([sys.executable, "-m", "venv", str(venv_dir)], check=True)
scripts_dir = venv_dir / ("Scripts" if os.name == "nt" else "bin")
python = scripts_dir / ("python.exe" if os.name == "nt" else "python")
subprocess.run([str(python), "-m", "pip", "install", "--upgrade", "pip"], check=True)
subprocess.run([str(python), "-m", "pip", "install", "-r", str(temp_input)], check=True)
freeze = subprocess.run(
[str(python), "-m", "pip", "freeze"],
check=True,
capture_output=True,
text=True,
)
resolved = [line for line in freeze.stdout.splitlines() if line.strip()]
if any("==" not in line for line in resolved):
raise SystemExit(f"{name} requirements were not fully resolved")
target.write_text("\n".join(resolved) + "\n", encoding="utf-8")
print(f"Resolved {len(resolved)} packages for {name} -> {target}")
return len(resolved)
main_count = resolve_requirements(
"main",
load_project_dependencies(root / "pyproject.toml")
+ load_requirements(root / "requirements.txt"),
output_dir / "requirements-main.txt",
)
sdk_count = resolve_requirements(
"sdk",
load_project_dependencies(root / "sdk" / "pyproject.toml"),
output_dir / "requirements-sdk.txt",
)
# Drop intra-monorepo deps (agentflow-client / agentflow-runtime) — they
# are not on PyPI yet during the v1.1.0 publish run and are scanned via
# the "main" / "sdk" buckets above anyway.
integrations_deps = [
dep for dep in load_project_dependencies(root / "integrations" / "pyproject.toml")
if not dep.lower().startswith(("agentflow-client", "agentflow-runtime"))
]
integrations_count = resolve_requirements(
"integrations",
integrations_deps,
output_dir / "requirements-integrations.txt",
)
def load_optional_dependencies(pyproject_path: Path, extra: str) -> list[str]:
if not pyproject_path.exists():
return []
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
return list(data.get("project", {}).get("optional-dependencies", {}).get(extra, []))
# Audit P1-3: scan every published/deployed extra, not just the
# unconditional core dependencies — cloud, postgres, the root
# "integrations" extra (langchain/langgraph/llama-index; distinct
# from the standalone integrations/pyproject.toml package resolved
# above), load, and contract. Each extra resolves in its own
# venv, same as main/sdk/integrations above, so unrelated extras
# never have to share one dependency graph. Safety only reads the
# frozen pins below, so the buckets do not need to be mutually
# installable.
extras_counts: dict[str, int] = {}
for extra_name in ("cloud", "postgres", "integrations", "load", "contract"):
extras_counts[extra_name] = resolve_requirements(
f"extra-{extra_name}",
load_optional_dependencies(root / "pyproject.toml", extra_name),
output_dir / f"requirements-extra-{extra_name}.txt",
)
# The Flink runtime is not an extra (its beam chain can never
# co-install with core pyarrow>=17) — its manifest lives next to
# the Flink image Dockerfile. Scan it as its own bucket.
flink_count = resolve_requirements(
"flink-runtime",
load_requirements(
root / "src" / "processing" / "flink_jobs" / "requirements.txt"
),
output_dir / "requirements-flink-runtime.txt",
)
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_path:
summary = Path(summary_path)
with summary.open("a", encoding="utf-8") as handle:
handle.write("## Safety dependency scope\n")
handle.write(
f"- Main app runtime: resolved install of `pyproject.toml` `[project.dependencies]` + `requirements.txt` ({main_count} packages)\n"
)
handle.write(
f"- SDK runtime: resolved install of `sdk/pyproject.toml` `[project.dependencies]` ({sdk_count} packages)\n"
)
handle.write(
f"- Integrations runtime: resolved install of `integrations/pyproject.toml` `[project.dependencies]` ({integrations_count} packages)\n"
)
for extra_name, extra_count in extras_counts.items():
handle.write(
f"- Extra `[{extra_name}]` (root `pyproject.toml`): resolved install ({extra_count} packages)\n"
)
handle.write(
f"- Flink runtime (`src/processing/flink_jobs/requirements.txt`): resolved install ({flink_count} packages)\n"
)
handle.write("- Exclusions: dev/CI/test extras, local tooling, and unrelated Docker image packages\n")
PY
- name: Install Safety
run: python -m pip install --upgrade pip "safety<3"
- name: Verify Safety fails on a known vulnerable pin
run: |
printf 'urllib3==1.24.1\n' > .tmp-security/requirements-regression.txt
if safety check -r .tmp-security/requirements-regression.txt > .tmp-security/safety-regression.log 2>&1; then
cat .tmp-security/safety-regression.log
echo "Safety unexpectedly passed the vulnerable regression probe"
exit 1
fi
if ! grep -q "urllib3" .tmp-security/safety-regression.log; then
cat .tmp-security/safety-regression.log
echo "Safety failed the regression probe for an unexpected reason"
exit 1
fi
- name: Run Safety
run: |
# 88512: Cross-ecosystem false positive — PyUp advisory text
# references @langchain/google-cloud-sql-pg (npm) but is matched
# against Python langchain<1.2.24, which does not exist (latest is
# 1.2.15). Remove this ignore when PyUp corrects the entry.
#
# SFTY-20260217-93940 (CVE-2026-25087): pyarrow use-after-free when
# decoding malformed IPC files, fixed in 23.0.1. Only the Flink
# runtime bucket is affected: apache-flink's beam chain caps
# pyarrow<17, so the fixed version cannot install there (the API
# image and every extra run pyarrow>=23.0.1 via uv.lock, gated by
# pip-audit). The Flink jobs only exchange Arrow IPC that beam
# itself produces inside the pipeline, never operator-supplied IPC
# files. Remove when apache-flink/beam raise their pyarrow floor
# past 23.0.1.
safety check \
--ignore 88512 \
--ignore SFTY-20260217-93940 \
-r .tmp-security/requirements-main.txt \
-r .tmp-security/requirements-sdk.txt \
-r .tmp-security/requirements-integrations.txt \
-r .tmp-security/requirements-extra-cloud.txt \
-r .tmp-security/requirements-extra-postgres.txt \
-r .tmp-security/requirements-extra-integrations.txt \
-r .tmp-security/requirements-flink-runtime.txt \
-r .tmp-security/requirements-extra-load.txt \
-r .tmp-security/requirements-extra-contract.txt
# Audit P1-3: pip-audit against the hash-pinned export of uv.lock. The
# unlocked run could not even finish resolving in three minutes; with the
# complete pinned set (ci.yml lock-check proves completeness) there is
# nothing to resolve — every pin is checked against the advisory DBs
# directly.
pip-audit:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"
- name: Install pip-audit
run: python -m pip install --upgrade pip "pip-audit>=2.7,<3"
- name: Audit the locked production dependency set
run: pip-audit --no-deps -r requirements-docker.lock
npm-audit:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "20"
- name: Install TS SDK deps from lockfile
working-directory: sdk-ts
run: npm ci
- name: npm audit
working-directory: sdk-ts
run: npm audit --audit-level=moderate
trivy:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Build API image
env:
COMPOSE_PROJECT_NAME: agentflow-security
# docker compose validates env vars across all services even when
# building only one. agentflow-api itself does not consume these.
CLICKHOUSE_USER: scan-only
CLICKHOUSE_PASSWORD: scan-only
GF_SECURITY_ADMIN_USER: scan-only
GF_SECURITY_ADMIN_PASSWORD: scan-only
run: |
docker compose -f docker-compose.prod.yml build agentflow-api
if (-not (docker image inspect agentflow-security-agentflow-api:latest 2>$null)) {
throw "agentflow-security-agentflow-api:latest was not built"
}
docker tag agentflow-security-agentflow-api:latest agentflow-api:security-scan
shell: pwsh
- name: Generate CycloneDX SBOM
# Pinned to release tag (audit p9 #3); update by bumping the
# tag, never re-pin to @master (allows upstream to alter scanner).
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: agentflow-api:security-scan
format: cyclonedx
output: agentflow-api.cdx.json
- name: Upload CycloneDX SBOM
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: agentflow-api-sbom-cyclonedx
path: agentflow-api.cdx.json
if-no-files-found: error
- name: Run Trivy scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: agentflow-api:security-scan
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: "1"
# Without this, trivy-action builds the SARIF "with all severities"
# and drops BOTH filters above from the scan the exit code comes
# from — an unfixable MEDIUM in the base image (e.g. liblzma5
# CVE-2026-34743, no Debian fix available) fails the gate that
# declares itself HIGH,CRITICAL-only. This makes the declared
# filters real for the SARIF scan too.
limit-severities-for-sarif: true
- name: Upload Trivy scan results
if: always()
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
sarif_file: trivy-results.sarif