feat(sdk): portable Argus CLI — pip install, Docker execution, MCP server, CI preflight #8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |