Skip to content

feat(sdk): portable Argus CLI — pip install, Docker execution, MCP server, CI preflight #8

feat(sdk): portable Argus CLI — pip install, Docker execution, MCP server, CI preflight

feat(sdk): portable Argus CLI — pip install, Docker execution, MCP server, CI preflight #8

name: Build, Scan & Test Containers
# Single workflow for container lifecycle on PRs:
# 1. Build custom images from the branch (once)
# 2. Scan images with Trivy + Grype
# 3. Test argus CLI using the built images
# 4. Post PR comment with aggregated results
#
# Images are built ONCE and shared via docker save/load artifacts.
# No triple-build. No masked failures.
on:
pull_request:
paths:
- 'docker/**'
- 'argus/**'
- 'argus.yml'
- '.github/workflows/build-containers.yml'
push:
branches: [main]
paths:
- 'docker/**'
- 'argus/**'
workflow_dispatch:
permissions:
contents: read
security-events: write
pull-requests: write
concurrency:
group: containers-${{ github.ref }}
cancel-in-progress: true
jobs:
# ── Step 1: Build all custom images ──────────────────────────────────
build:
name: Build Images
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: true
matrix:
include:
- image: scanner-bandit
dockerfile: docker/Dockerfile.bandit
- image: scanner-opengrep
dockerfile: docker/Dockerfile.opengrep
- image: scanner-supply-chain
dockerfile: docker/Dockerfile.supply-chain
- image: cli
dockerfile: docker/Dockerfile.cli
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Build image
run: |
docker build \
--tag "${IMAGE_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" \
--file "${DOCKERFILE}" \
--label "org.opencontainers.image.revision=${COMMIT_SHA}" \
.
env:
IMAGE_REGISTRY: ghcr.io/huntridge-labs/argus
IMAGE_NAME: ${{ matrix.image }}
IMAGE_TAG: ${{ github.sha }}
DOCKERFILE: ${{ matrix.dockerfile }}
COMMIT_SHA: ${{ github.sha }}
- name: Save image to artifact
run: |
docker save "${IMAGE_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" \
| gzip > "/tmp/${IMAGE_NAME}.tar.gz"
env:
IMAGE_REGISTRY: ghcr.io/huntridge-labs/argus
IMAGE_NAME: ${{ matrix.image }}
IMAGE_TAG: ${{ github.sha }}
- name: Upload image artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: image-${{ matrix.image }}
path: /tmp/${{ matrix.image }}.tar.gz
retention-days: 1
# ── Step 2: Scan each image with Trivy + Grype ──────────────────────
scan:
name: Scan ${{ matrix.image }}
needs: [build]
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- image: scanner-bandit
- image: scanner-opengrep
- image: scanner-supply-chain
- image: cli
steps:
- name: Download image artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
with:
name: image-${{ matrix.image }}
path: /tmp
- name: Load image
run: |
gunzip -c "/tmp/${IMAGE_NAME}.tar.gz" | docker load
env:
IMAGE_NAME: ${{ matrix.image }}
- name: Scan with Trivy
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
with:
image-ref: "ghcr.io/huntridge-labs/argus/${{ matrix.image }}:${{ github.sha }}"
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy SARIF
if: always()
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4
with:
sarif_file: trivy-results.sarif
category: "container-${{ matrix.image }}"
continue-on-error: true
- name: Scan with Grype
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7
with:
image: "ghcr.io/huntridge-labs/argus/${{ matrix.image }}:${{ github.sha }}"
fail-build: false
severity-cutoff: critical
# Run Trivy again with JSON for severity breakdown
- name: Scan with Trivy (JSON)
if: always()
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
with:
image-ref: "ghcr.io/huntridge-labs/argus/${{ matrix.image }}:${{ github.sha }}"
format: 'json'
output: 'trivy-results.json'
- name: Generate scan report
if: always()
run: |
mkdir -p scan-reports
python3 << 'PYEOF'
import json, os
image_name = os.environ["IMAGE_NAME"]
report = {"image": image_name, "critical": 0, "high": 0, "medium": 0, "low": 0, "total": 0, "top_findings": []}
# Parse Trivy JSON for severity counts and details
try:
data = json.load(open("trivy-results.json"))
seen = set()
for result in data.get("Results", []):
for vuln in result.get("Vulnerabilities", []):
vid = vuln.get("VulnerabilityID", "")
if vid in seen:
continue
seen.add(vid)
sev = vuln.get("Severity", "UNKNOWN").upper()
if sev == "CRITICAL":
report["critical"] += 1
elif sev == "HIGH":
report["high"] += 1
elif sev == "MEDIUM":
report["medium"] += 1
elif sev == "LOW":
report["low"] += 1
report["total"] += 1
if len(report["top_findings"]) < 15:
report["top_findings"].append({
"id": vid,
"severity": sev,
"pkg": vuln.get("PkgName", ""),
"installed": vuln.get("InstalledVersion", ""),
"fixed": vuln.get("FixedVersion", ""),
"title": vuln.get("Title", "")[:80],
})
except Exception as e:
report["error"] = str(e)
json.dump(report, open(f"scan-reports/{image_name}.json", "w"), indent=2)
PYEOF
env:
IMAGE_NAME: ${{ matrix.image }}
- name: Upload scan report
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: scan-report-${{ matrix.image }}
path: scan-reports/
retention-days: 7
# ── Step 3: Test argus CLI using built images ───────────────────────
test-cli:
name: Test Argus CLI
needs: [build]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.13'
- name: Install Argus
run: |
pip install --quiet pyyaml
echo "PYTHONPATH=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
# Load all custom images built in step 1
- name: Download all image artifacts
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
with:
pattern: image-*
merge-multiple: true
path: /tmp/images
- name: Load and retag images
run: |
for tarball in /tmp/images/*.tar.gz; do
gunzip -c "$tarball" | docker load
done
# Retag from SHA to version tag that containers.py expects
for image in scanner-bandit scanner-opengrep scanner-supply-chain cli; do
SHA_TAG="ghcr.io/huntridge-labs/argus/${image}:${GITHUB_SHA}"
VERSION_TAG="ghcr.io/huntridge-labs/argus/${image}:1.0.0"
if docker image inspect "$SHA_TAG" > /dev/null 2>&1; then
docker tag "$SHA_TAG" "$VERSION_TAG"
fi
done
env:
GITHUB_SHA: ${{ github.sha }}
- name: Test scanner listing
run: |
OUTPUT=$(python -m argus scan --list)
echo "$OUTPUT"
for scanner in bandit clamav trivy-iac gitleaks osv checkov opengrep supply-chain zap container; do
echo "$OUTPUT" | grep -q "$scanner" || { echo "FAIL: $scanner not listed"; exit 1; }
done
- name: Run argus scan
run: |
set +e
python -m argus scan \
--config argus.yml \
--format terminal \
--format sarif \
--format json \
--output-dir ./cli-test-results \
--verbose
EXIT_CODE=$?
set -e
if [ "$EXIT_CODE" -eq 2 ]; then
echo "::error::Argus scan failed with error (exit code 2)"
exit 2
fi
echo "Scan completed with exit code $EXIT_CODE"
- name: Verify outputs
run: |
for f in cli-test-results/argus-results.json cli-test-results/argus-results.sarif; do
test -f "$f" || { echo "FAIL: $f not produced"; exit 1; }
done
- name: Validate SARIF
run: |
python3 -c "
import json
data = json.load(open('cli-test-results/argus-results.sarif'))
assert data.get('version') == '2.1.0', f'Bad SARIF version: {data.get(\"version\")}'
assert 'runs' in data, 'Missing runs'
print(f'SARIF valid: {len(data[\"runs\"])} run(s)')
"
- name: Validate JSON results
run: |
python3 -c "
import json
data = json.load(open('cli-test-results/argus-results.json'))
results = data.get('results', [])
assert len(results) > 0, 'No scanner results'
scanners = [r['scanner'] for r in results]
total = sum(len(r.get('findings', [])) for r in results)
print(f'Scanners: {scanners}')
print(f'Total findings: {total}')
"
- name: Upload test results
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: argus-cli-test-results
path: cli-test-results/
retention-days: 7
# ── Step 4: Post PR comment with results ────────────────────────────
comment-pr:
name: Container Scan Summary
if: always() && github.event_name == 'pull_request'
needs: [scan, test-cli]
runs-on: ubuntu-latest
steps:
- name: Download scan reports
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
with:
pattern: scan-report-*
merge-multiple: true
path: scan-reports
- name: Download CLI test results
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
with:
name: argus-cli-test-results
path: cli-results
continue-on-error: true
- name: Generate PR comment
run: |
python3 << 'PYEOF'
import json, glob, os
lines = []
lines.append("## 🔒 Argus Container Security Scan")
lines.append("")
# ── Overall summary table ──
reports = sorted(glob.glob("scan-reports/*.json"))
totals = {"critical": 0, "high": 0, "medium": 0, "low": 0}
image_data = []
for path in reports:
data = json.load(open(path))
image_data.append(data)
for sev in totals:
totals[sev] += data.get(sev, 0)
lines.append("### 📊 Findings Summary")
lines.append("")
lines.append("| 🚨 Critical | ⚠️ High | 🟡 Medium | 🔵 Low | 📦 Total |")
lines.append("|:-----------:|:-------:|:---------:|:------:|:--------:|")
total = sum(totals.values())
lines.append(f"| **{totals['critical']}** | **{totals['high']}** | **{totals['medium']}** | **{totals['low']}** | **{total}** |")
lines.append("")
# ── Per-image breakdown ──
for data in image_data:
name = data["image"]
c, h, m, l = data.get("critical", 0), data.get("high", 0), data.get("medium", 0), data.get("low", 0)
img_total = c + h + m + l
if img_total == 0:
lines.append(f"<details>")
lines.append(f"<summary>📦 {name} — ✅ Clean</summary>")
lines.append("")
lines.append("No vulnerabilities found at any severity level.")
lines.append("")
lines.append("</details>")
else:
lines.append(f"<details>")
lines.append(f"<summary>📦 {name} — {img_total} finding(s) (🚨{c} ⚠️{h} 🟡{m} 🔵{l})</summary>")
lines.append("")
lines.append("| 🚨 Critical | ⚠️ High | 🟡 Medium | 🔵 Low | Total |")
lines.append("|:-----------:|:-------:|:---------:|:------:|:-----:|")
lines.append(f"| **{c}** | **{h}** | **{m}** | **{l}** | **{img_total}** |")
lines.append("")
findings = data.get("top_findings", [])
if findings:
lines.append("| CVE | Severity | Package | Installed | Fixed | Title |")
lines.append("|-----|----------|---------|-----------|-------|-------|")
for f in findings:
sev = f.get("severity", "")
sev_icon = {"CRITICAL": "🚨", "HIGH": "⚠️", "MEDIUM": "🟡", "LOW": "🔵"}.get(sev, "")
lines.append(
f"| {f.get('id', '')} | {sev_icon} {sev} | {f.get('pkg', '')} | {f.get('installed', '')} | {f.get('fixed', 'N/A')} | {f.get('title', '')} |"
)
if img_total > len(findings):
lines.append(f"| | | | | | *...and {img_total - len(findings)} more* |")
lines.append("")
lines.append("</details>")
lines.append("")
# ── Argus CLI scan summary ──
cli_json = "cli-results/argus-results.json"
if os.path.exists(cli_json):
data = json.load(open(cli_json))
results = data.get("results", [])
scanners = [r["scanner"] for r in results]
total_findings = sum(len(r.get("findings", [])) for r in results)
lines.append("<details>")
lines.append(f"<summary>🔍 Argus CLI Scan — {len(scanners)} scanner(s), {total_findings} finding(s)</summary>")
lines.append("")
for r in results:
scanner = r["scanner"]
count = len(r.get("findings", []))
icon = "✅" if count == 0 else "⚠️"
lines.append(f"- {icon} **{scanner}**: {count} finding(s)")
lines.append("")
lines.append("</details>")
lines.append("")
lines.append("---")
lines.append("*Scanned with Trivy + Grype (containers) and Argus CLI (code). SARIF uploaded to Security tab.*")
body = "\n".join(lines)
with open("comment-body.md", "w") as f:
f.write(body)
print(body)
PYEOF
- name: Post PR comment
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
BODY=$(cat comment-body.md)
MARKER="<!-- argus-container-scan -->"
# Find existing comment by marker
COMMENT_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
--jq ".[] | select(.body | startswith(\"${MARKER}\")) | .id" 2>/dev/null | head -1)
FULL_BODY="${MARKER}
${BODY}"
if [ -n "$COMMENT_ID" ]; then
gh api "repos/${REPO}/issues/comments/${COMMENT_ID}" \
-X PATCH -f body="$FULL_BODY"
else
gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
-f body="$FULL_BODY"
fi