diff --git a/.gitignore b/.gitignore index 66ccb20..c56fa24 100644 --- a/.gitignore +++ b/.gitignore @@ -1,62 +1,54 @@ - -.idea -venv -.venv -build -dist -*.build -*.dist -*.egg-info -test -*.env -run_container.sh -*.zip -bin -scripts/*.py -*.json -markdown_overview_temp.md -markdown_security_temp.md +# OS files .DS_Store -*.pyc -test.py -# Note: requirements.txt is no longer needed - using pyproject.toml + uv.lock instead -# Version files are auto-managed by .hooks/version-check.py -*.cpython-312.pyc -file_generator.py -.env -*.md -test_results -local_tests/ -custom_rules/ +# IDEs and editors +.idea/ +.vscode/ +*.sublime-workspace +*.sublime-project +*.swp +*~ -# Common Python ignores +# Python __pycache__/ *.py[cod] *$py.class +.python-version + +# Virtual environments +venv/ +.venv/ +env/ +ENV/ +env.bak/ +venv.bak/ + +# Build and distribution +build/ +dist/ +*.build +*.dist +*.egg-info +.eggs/ +*.egg + +# Testing and coverage .pytest_cache/ .mypy_cache/ .coverage .coverage.* htmlcov/ +coverage/ +coverage.xml +nosetests.xml +test-results/ +test_results/ + +# pip pip-wheel-metadata/ pip-log.txt pip-delete-this-directory.txt -# Virtual environments -env/ -ENV/ -env.bak/ -venv.bak/ - -# IDEs and editors -.vscode/ -.idea/ -*.sublime-workspace -*.sublime-project -*.swp -*~ - # Node node_modules/ npm-debug.log* @@ -64,26 +56,19 @@ yarn-debug.log* yarn-error.log* .pnp/ -# Build and distribution -.eggs/ -*.egg -dist/ -build/ - -# Coverage and test output -coverage/ -coverage.xml -nosetests.xml -test-results/ +# Jupyter +.ipynb_checkpoints/ # Logs and runtime files -logs/ - +*.log *.pid *.sock +logs/ -# OS files -.DS_Store +# Temporary files +*.tmp +*.temp +*.zip # Binary and compiled *.exe @@ -91,16 +76,25 @@ logs/ *.so *.dylib -# Jupyter -.ipynb_checkpoints/ - -# Local temporary files -*.tmp -*.temp -# Ignore output logs and generated src files -*.log +# Environment and secrets +*.env -.python-version +# Data files (generated) +*.json .socket.fact.json -custom_rules/ \ No newline at end of file +# Markdown: ignore all except documentation +*.md +!README.md +!docs/*.md +!tests/README.md + +# Project-specific (local scripts and test files) +test/ +test.py +run_container.sh +bin/ +scripts/*.py +file_generator.py +local_tests/ +custom_rules/ diff --git a/README.md b/README.md index 2a4fa29..1a0e74e 100644 --- a/README.md +++ b/README.md @@ -11,22 +11,28 @@ Socket Basics orchestrates multiple security scanners, normalizes their outputs The easiest way to use Socket Basics is through GitHub Actions. Add it to your workflow in minutes: ```yaml -name: Security Scan +# .github/workflows/socket.yml + +name: โšก๏ธ Security Scan + on: pull_request: types: [opened, synchronize, reopened] +permissions: + contents: read + jobs: security-scan: permissions: issues: write contents: read pull-requests: write - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Run Socket Basics + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: โšก๏ธ Run Socket Basics uses: SocketDev/socket-basics@1.0.28 env: GITHUB_PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} @@ -72,10 +78,26 @@ Socket Basics can also run locally or in other CI/CD environments: - Auto-enablement for container scanning when images or Dockerfiles are specified - Support for both standard and GitHub Actions `INPUT_*` environment variables +## ๐ŸŽจ Enhanced PR Comments + +Socket Basics delivers **beautifully formatted, actionable PR comments** with smart defaults โ€” all enabled by default, zero configuration needed. + +- ๐Ÿ”— **Clickable File Links** โ€” Jump directly to the vulnerable code in GitHub +- ๐Ÿ“‹ **Collapsible Sections** โ€” Critical findings auto-expand, others collapse +- ๐ŸŽจ **Syntax Highlighting** โ€” Language-aware code blocks +- ๐Ÿท๏ธ **Auto-Labels** โ€” PRs tagged with severity-based labels (e.g., `security: critical`) +- ๐Ÿ”ด **CVE Links & CVSS Scores** โ€” One-click access to NVD with risk context +- ๐Ÿš€ **Full Scan Link** โ€” Report link prominently displayed at the top + +Every feature is customizable via GitHub Actions inputs, CLI flags, or environment variables. + +๐Ÿ“– **[PR Comment Guide โ†’](docs/github-pr-comment-guide.md)** โ€” Full customization options, examples, and configuration reference + ## ๐Ÿ“– Documentation ### Getting Started - [GitHub Actions Integration](docs/github-action.md) โ€” Complete guide with workflow examples +- [PR Comment Guide](docs/github-pr-comment-guide.md) โ€” Detailed guide to PR comment customization - [Pre-Commit Hook Setup](docs/pre-commit-hook.md) โ€” Two installation methods (Docker vs native) - [Local Docker Installation](docs/local-install-docker.md) โ€” Run with Docker, no tools to install - [Local Installation](docs/local-installation.md) โ€” Install Socket CLI, Trivy, and other tools natively @@ -106,34 +128,9 @@ Configure scanning policies, notification channels, and rule sets for your entir ![Socket Basics Section Config](docs/screenshots/socket_basics_section_config.png) -## ๐Ÿ’ป Usage Examples - -### GitHub Actions (Recommended) +## ๐Ÿ’ป Other Usage Methods -**Dashboard-Configured (Enterprise):** -```yaml -- uses: SocketDev/socket-basics@1.0.28 - env: - GITHUB_PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - socket_security_api_key: ${{ secrets.SOCKET_SECURITY_API_KEY }} - # All configuration managed in Socket Dashboard -``` - -**CLI-Configured:** -```yaml -- uses: SocketDev/socket-basics@1.0.28 - env: - GITHUB_PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - python_sast_enabled: 'true' - secret_scanning_enabled: 'true' - container_images: 'myapp:latest' -``` - -๐Ÿ“– **[View Complete GitHub Actions Documentation](docs/github-action.md)** +For GitHub Actions, see the [Quick Start](#-quick-start---github-actions) above or the **[Complete GitHub Actions Guide](docs/github-action.md)** for advanced workflows. ### Docker @@ -257,8 +254,135 @@ We welcome contributions! To add new features: 1. **New Connectors:** Implement under `socket_basics/core/connector/` 2. **New Notifiers:** Implement under `socket_basics/core/notification/` 3. **Configuration:** Add entries to `socket_basics/connectors.yaml` or `socket_basics/notifications.yaml` -4. **Tests:** Add test cases to `app_tests/` +4. **Tests:** See [Testing](#-testing) section below + +## ๐Ÿงช Testing + +Socket Basics uses a two-tier testing strategy to ensure code quality and scanner accuracy. + +### Test Structure + +``` +socket-basics/ +โ”œโ”€โ”€ tests/ # Unit & integration tests (pytest) +โ”‚ โ””โ”€โ”€ test_*.py # Fast, isolated tests of functions/modules +โ””โ”€โ”€ app_tests/ # End-to-end test fixtures + โ”œโ”€โ”€ juice-shop/ # Node.js vulnerable app + โ”œโ”€โ”€ pygoat/ # Python vulnerable app + โ”œโ”€โ”€ NodeGoat/ # Node.js vulnerable app + โ”œโ”€โ”€ DVWA/ # PHP vulnerable app + โ””โ”€โ”€ ... # Other deliberately vulnerable apps +``` + +### Running Unit Tests + +**Quick test run:** +```bash +# Setup (first time only) +python3 -m venv venv +source venv/bin/activate +pip install -e ".[dev]" + +# Run all unit tests +pytest + +# Run specific test file +pytest tests/test_github_helpers.py +# Run with verbose output +pytest -v + +# Run with coverage report +pytest --cov=socket_basics tests/ +``` + +**Characteristics:** +- โšก **Fast** โ€” Milliseconds to seconds +- ๐ŸŽฏ **Isolated** โ€” No external dependencies +- ๐Ÿ”„ **Frequent** โ€” Run on every commit +- โœ… **Validates** โ€” Code logic and functions + +### End-to-End Testing with Real Apps + +The `app_tests/` directory contains deliberately vulnerable applications (git submodules) for validating scanner accuracy. + +**Purpose:** +- Verify scanners detect **known vulnerabilities** +- Test against **real-world code patterns** +- Ensure **multi-language coverage** +- Validate **entire scan pipeline** + +**Run E2E tests:** +```bash +# Scan a vulnerable Node.js app +socket-basics --workspace app_tests/juice-shop \ + --javascript-sast-enabled \ + --secret-scanning-enabled + +# Scan a vulnerable Python app +socket-basics --workspace app_tests/pygoat \ + --python-sast-enabled \ + --secret-scanning-enabled + +# Compare results against known vulnerabilities +# (Manual verification of findings) +``` + +**Characteristics:** +- ๐Ÿข **Slow** โ€” Minutes per app +- ๐Ÿ“ฆ **Large** โ€” Git submodules with full apps +- ๐ŸŽฏ **Ground truth** โ€” Known vulnerabilities to detect +- โœ… **Validates** โ€” Scanner accuracy and coverage + +### Adding Tests + +**Adding Unit Tests:** +```python +# tests/test_new_feature.py +import pytest +from socket_basics.module import new_function + +def test_new_feature(): + result = new_function(input_data) + assert result == expected_output +``` + +**Adding E2E Test Fixtures:** +```bash +# Add a new vulnerable app as a git submodule +cd app_tests/ +git submodule add https://github.com/org/vulnerable-app +git submodule update --init +``` + +### Test Best Practices + +**For contributors:** +1. โœ… Add unit tests for new functions/modules +2. โœ… Run `pytest` before committing +3. โœ… Validate changes against `app_tests/` fixtures +4. โœ… Keep unit tests fast (mock external dependencies) + +**For security researchers:** +1. ๐Ÿ” Use `app_tests/` to validate scanner accuracy +2. ๐Ÿ“Š Compare findings against CVE databases +3. ๐ŸŽฏ Add new vulnerable apps as needed +4. ๐Ÿ“ Document expected findings for regression testing + +### CI/CD Testing + +```yaml +# Example GitHub Actions workflow +- name: Run Unit Tests + run: | + pip install -e ".[dev]" + pytest tests/ --cov=socket_basics + +- name: Run E2E Tests (Selected) + run: | + # Run against specific vulnerable apps + socket-basics --workspace app_tests/pygoat --python +``` --- diff --git a/action.yml b/action.yml index 1c15b6f..926a54b 100644 --- a/action.yml +++ b/action.yml @@ -87,6 +87,15 @@ runs: INPUT_WEBHOOK_URL: ${{ inputs.webhook_url }} SOCKET_ADDITIONAL_PARAMS: ${{ inputs.socket_additional_params }} SOCKET_TIER_1_ENABLED: ${{ inputs.socket_tier_1_enabled }} + INPUT_PR_COMMENT_LINKS_ENABLED: ${{ inputs.pr_comment_links_enabled }} + INPUT_PR_COMMENT_COLLAPSE_ENABLED: ${{ inputs.pr_comment_collapse_enabled }} + INPUT_PR_COMMENT_COLLAPSE_NON_CRITICAL: ${{ inputs.pr_comment_collapse_non_critical }} + INPUT_PR_COMMENT_CODE_FENCING_ENABLED: ${{ inputs.pr_comment_code_fencing_enabled }} + INPUT_PR_COMMENT_SHOW_RULE_NAMES: ${{ inputs.pr_comment_show_rule_names }} + INPUT_PR_LABELS_ENABLED: ${{ inputs.pr_labels_enabled }} + INPUT_PR_LABEL_CRITICAL: ${{ inputs.pr_label_critical }} + INPUT_PR_LABEL_HIGH: ${{ inputs.pr_label_high }} + INPUT_PR_LABEL_MEDIUM: ${{ inputs.pr_label_medium }} inputs: workspace: @@ -405,6 +414,42 @@ inputs: description: "Generic webhook URL for WebhookNotifier" required: false default: "" + pr_comment_links_enabled: + description: "Enable clickable file/line links in PR comments" + required: false + default: "true" + pr_comment_collapse_enabled: + description: "Enable collapsible sections in PR comments" + required: false + default: "true" + pr_comment_collapse_non_critical: + description: "Auto-collapse non-critical findings (critical stays expanded)" + required: false + default: "true" + pr_comment_code_fencing_enabled: + description: "Enable language-aware code fencing for trace output" + required: false + default: "true" + pr_comment_show_rule_names: + description: "Show explicit rule names for each finding" + required: false + default: "true" + pr_labels_enabled: + description: "Add severity-based labels to PRs" + required: false + default: "true" + pr_label_critical: + description: "Label name for critical severity findings" + required: false + default: "security: critical" + pr_label_high: + description: "Label name for high severity findings" + required: false + default: "security: high" + pr_label_medium: + description: "Label name for medium severity findings" + required: false + default: "security: medium" branding: icon: "shield" diff --git a/assets/socket-logo.png b/assets/socket-logo.png new file mode 100644 index 0000000..cbcb7d5 Binary files /dev/null and b/assets/socket-logo.png differ diff --git a/docs/github-action.md b/docs/github-action.md index db3d81e..6c027db 100644 --- a/docs/github-action.md +++ b/docs/github-action.md @@ -28,31 +28,29 @@ on: pull_request: types: [opened, synchronize, reopened] +permissions: + contents: read + jobs: security-scan: permissions: issues: write contents: read pull-requests: write - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run Socket Basics uses: SocketDev/socket-basics@1.0.28 env: GITHUB_PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} with: github_token: ${{ secrets.GITHUB_TOKEN }} - python_sast_enabled: 'true' - secret_scanning_enabled: 'true' + socket_security_api_key: ${{ secrets.SOCKET_SECURITY_API_KEY }} ``` -This will: -- โœ… Run Python SAST on all `.py` files -- โœ… Scan for leaked secrets -- โœ… Post results as a PR comment -- โœ… Post results as a PR comment +With just your `SOCKET_SECURITY_API_KEY`, all scanning configurations are managed through the [Socket Dashboard](https://socket.dev/dashboard) โ€” no workflow changes needed. ## Basic Configuration @@ -137,6 +135,12 @@ Include these in your workflow's `jobs..permissions` section. verbose: 'true' ``` +## PR Comment Customization + +Socket Basics automatically posts enhanced PR comments with **smart defaults that work out of the box** โ€” clickable file links, collapsible sections, syntax highlighting, CVE links, CVSS scores, and auto-labels are all enabled by default. + +๐Ÿ“– **[PR Comment Guide โ†’](github-pr-comment-guide.md)** โ€” Complete customization options, configuration examples, and reference table + ## Enterprise Features Socket Basics Enterprise features require a [Socket Enterprise](https://socket.dev/enterprise) subscription. @@ -269,7 +273,7 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run Socket Basics uses: SocketDev/socket-basics@1.0.28 @@ -315,7 +319,7 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run Full Security Scan uses: SocketDev/socket-basics@1.0.28 @@ -366,7 +370,7 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Build Docker Image run: docker build -t myapp:1.0.28:${{ github.sha }} . @@ -404,7 +408,7 @@ jobs: outputs: dockerfiles: ${{ steps.discover.outputs.dockerfiles }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Discover Dockerfiles id: discover @@ -432,7 +436,7 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run Socket Basics uses: SocketDev/socket-basics@1.0.28 @@ -484,7 +488,7 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run Socket Basics uses: SocketDev/socket-basics@1.0.28 @@ -576,10 +580,10 @@ env: **Problem:** Scanner reports no files found. -**Solution:** Ensure `actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683` runs before Socket Basics: +**Solution:** Ensure `actions/checkout` runs before Socket Basics: ```yaml steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - Must be first + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - Must be first - uses: SocketDev/socket-basics@1.0.28 ``` diff --git a/docs/github-pr-comment-guide.md b/docs/github-pr-comment-guide.md new file mode 100644 index 0000000..6a9f7c3 --- /dev/null +++ b/docs/github-pr-comment-guide.md @@ -0,0 +1,495 @@ +# GitHub PR Comment Guide + +Socket Basics delivers **beautifully formatted, actionable GitHub PR comments** that help developers quickly understand and address security findings. + +## ๐ŸŒŸ Universal Features + +These enhancements work across **all scanner types**: +- โœ… **Socket Tier 1** (Reachability Analysis) +- โœ… **SAST** (OpenGrep/Semgrep) +- โœ… **Container Scanning** (Trivy) +- โœ… **Secret Detection** (TruffleHog) +- โœ… **Future OSS Tools** (via centralized architecture) + +All scanners share the same UX enhancements for a consistent, professional experience. + +## ๐ŸŽฏ Quick Start + +**Want the enhanced experience?** You already have it! All features are **enabled by default** with the standard workflow setup โ€” see the [Quick Start in README](../README.md#-quick-start---github-actions). + +## โœจ Features + +### 1. Clickable File Links (`pr_comment_links_enabled`) + +**Default:** `true` + +Jump directly to vulnerable code in GitHub with one click. + +**Before:** +``` +owasp-goat - server.js 72:12-75:6 + -> express routes/auth.js 45:2 +``` + +**After:** +``` +owasp-goat - [server.js 72:12-75:6](https://github.com/owner/repo/blob/abc123/server.js#L72-L75) + -> express [routes/auth.js 45:2](https://github.com/owner/repo/blob/abc123/routes/auth.js#L45) +``` + +**Disable:** +```yaml +pr_comment_links_enabled: 'false' +``` + +--- + +### 2. Collapsible Sections (`pr_comment_collapse_enabled`) + +**Default:** `true` (critical auto-expands, others collapse) + +Organize findings with expandable sections for easy scanning. + +**Before:** +```markdown +#### pkg:npm/lodash@4.17.20 + +๐Ÿ”ด CVE-2021-23337: CRITICAL +... + +๐ŸŸ  CVE-2021-23338: HIGH +... +``` + +**After:** +```markdown +
+pkg:npm/lodash@4.17.20 (๐Ÿ”ด Critical: 1 | ๐ŸŸ  High: 1) + +๐Ÿ”ด CVE-2021-23337: CRITICAL +... + +๐ŸŸ  CVE-2021-23338: HIGH +... + +
+``` + +**Options:** +```yaml +# Disable collapsible sections entirely +pr_comment_collapse_enabled: 'false' + +# Keep collapsible but expand everything +pr_comment_collapse_enabled: 'true' +pr_comment_collapse_non_critical: 'false' +``` + +--- + +### 3. Syntax Highlighting (`pr_comment_code_fencing_enabled`) + +**Default:** `true` + +Language-aware code blocks based on file extension. + +**Before:** +``` +owasp-goat - server.js 72:12-75:6 + -> express routes/auth.js 45:2 +``` + +**After:** +````markdown +```javascript +owasp-goat - [server.js 72:12-75:6](https://github.com/owner/repo/blob/abc123/server.js#L72-L75) + -> express [routes/auth.js 45:2](https://github.com/owner/repo/blob/abc123/routes/auth.js#L45) +``` +```` + +**Supported languages:** +- JavaScript/TypeScript (`.js`, `.jsx`, `.ts`, `.tsx`) +- Python (`.py`) +- Go (`.go`) +- Java (`.java`) +- Ruby (`.rb`) +- PHP (`.php`) +- And 20+ more... + +**Disable:** +```yaml +pr_comment_code_fencing_enabled: 'false' +``` + +--- + +### 4. Explicit Rule Names (`pr_comment_show_rule_names`) + +**Default:** `true` + +Clearly identify which security rule was triggered. + +**Before:** +``` +๐Ÿ”ด CVE-2021-23337: CRITICAL +``` + +**After:** +``` +๐Ÿ”ด CVE-2021-23337: CRITICAL +**Rule**: `CVE-2021-23337` +``` + +**Disable:** +```yaml +pr_comment_show_rule_names: 'false' +``` + +--- + +### 5. CVE Links & CVSS Scores + +**Default:** Always enabled (automatic for CVE/vulnerability findings) + +Make vulnerability IDs clickable and display CVSS scores for better risk assessment. + +**Before:** +```markdown +๐Ÿ”ด CVE-2021-23337: CRITICAL +``` + +**After:** +```markdown +๐Ÿ”ด **[CVE-2021-23337](https://nvd.nist.gov/vuln/detail/CVE-2021-23337)** โ€ข CRITICAL (CVSS 9.8) +``` + +**How it works:** +- CVE IDs automatically become clickable links to the National Vulnerability Database (NVD) +- CVSS scores are displayed when available from the scanner +- Works for Socket Tier 1 and Trivy container/CVE scanning +- Missing CVSS scores are gracefully omitted (no breaking changes) + +**Example with different formats:** +```markdown +# With CVSS score +๐Ÿ”ด **[CVE-2021-44228](https://nvd.nist.gov/vuln/detail/CVE-2021-44228)** โ€ข CRITICAL (CVSS 10.0) + +# Without CVSS score (not available) +๐ŸŸ  **[CVE-2021-23338](https://nvd.nist.gov/vuln/detail/CVE-2021-23338)** โ€ข HIGH + +# Non-CVE vulnerabilities (GHSA, etc.) +๐Ÿ”ด **GHSA-abcd-1234-efgh** โ€ข CRITICAL +``` + +**Benefits:** +- One-click access to detailed vulnerability information +- CVSS scores provide standardized risk context +- Helps developers prioritize remediation efforts +- Links to official NVD entries with full CVE details + +--- + +### 6. Full Scan Link at Top + +**Default:** Always enabled (when URL available) + +Quick access to the complete scan report. + +**Before:** +```markdown +# Socket Security Tier 1 Results + +### Summary +๐Ÿ”ด Critical: 3 | ๐ŸŸ  High: 14 | ๐ŸŸก Medium: 0 | โšช Low: 0 + +### Details +... + +--- +๐Ÿ”— [View Full Socket Scan](https://socket.dev/scan/123) +``` + +**After:** +```markdown +# Socket Security Tier 1 Results + +๐Ÿ”— **[View Full Socket Scan Report](https://socket.dev/scan/123)** + +--- + +### Summary +๐Ÿ”ด Critical: 3 | ๐ŸŸ  High: 14 | ๐ŸŸก Medium: 0 | โšช Low: 0 + +### Details +... +``` + +--- + +### 7. Auto-Labels with Colors (`pr_labels_enabled`) + +**Default:** `true` + +Automatically tag PRs with severity-based labels **and matching colors**. + +**Labels added (with automatic color assignment):** +- `security: critical` ๐Ÿ”ด - Red (`#D73A4A`) +- `security: high` ๐ŸŸ  - Orange (`#D93F0B`) +- `security: medium` ๐ŸŸก - Yellow (`#FBCA04`) + +**Smart color detection:** +Labels are automatically created with colors matching the severity emojis. If you customize label names, the system intelligently detects severity keywords and applies appropriate colors: + +```yaml +pr_label_critical: 'vulnerability: critical' # Gets red color automatically +pr_label_high: 'security-high' # Gets orange color automatically +``` + +**How it works:** +- First scan checks for critical โ†’ high โ†’ medium (highest severity wins) +- Labels are created automatically if they don't exist +- Existing labels are not modified (preserves your customizations) + +**Customize:** +```yaml +pr_labels_enabled: 'true' +pr_label_critical: 'vulnerability: critical' +pr_label_high: 'vulnerability: high' +pr_label_medium: 'vulnerability: medium' +``` + +**Disable:** +```yaml +pr_labels_enabled: 'false' +``` + +--- + +### 8. Logo Branding + +**Default:** Always enabled + +Every PR comment section includes the Socket shield logo inline with the title header for consistent branding. + +```markdown +## Socket Security Tier 1 +``` + +The logo is a 32px PNG rendered at 24x24 for retina-crisp display, with a transparent background that works in both GitHub light and dark modes. + +--- + +## ๐Ÿ“‹ Configuration Reference + +### All Options + +| Option | Default | Type | Description | +|--------|---------|------|-------------| +| `pr_comment_links_enabled` | `true` | boolean | Enable clickable file/line links | +| `pr_comment_collapse_enabled` | `true` | boolean | Enable collapsible sections | +| `pr_comment_collapse_non_critical` | `true` | boolean | Auto-collapse non-critical findings | +| `pr_comment_code_fencing_enabled` | `true` | boolean | Enable syntax highlighting | +| `pr_comment_show_rule_names` | `true` | boolean | Show explicit rule names | +| `pr_labels_enabled` | `true` | boolean | Add severity-based labels to PRs | +| `pr_label_critical` | `"security: critical"` | string | Label name for critical findings | +| `pr_label_high` | `"security: high"` | string | Label name for high findings | +| `pr_label_medium` | `"security: medium"` | string | Label name for medium findings | + +### Configuration Methods + +**1. GitHub Actions (Recommended)** + +Add these parameters to the `with:` block in your workflow (see [Quick Start](../README.md#-quick-start---github-actions)): +```yaml +pr_comment_links_enabled: 'true' +pr_label_critical: 'security: critical' +``` + +**2. CLI Arguments** +```bash +socket-basics \ + --pr-comment-links \ + --pr-comment-collapse \ + --pr-label-critical "security: critical" +``` + +**3. Environment Variables** +```bash +export INPUT_PR_COMMENT_LINKS_ENABLED=true +export INPUT_PR_LABEL_CRITICAL="security: critical" +``` + +--- + +## ๐ŸŽจ Common Configurations + +All examples below show only the `with:` parameters to add to your workflow. See the [Quick Start](../README.md#-quick-start---github-actions) for the full workflow setup. + +### Default (Recommended) + +Everything enabled with sensible defaults โ€” no extra parameters needed. + +### Minimal (Plaintext) + +Simple text output without enhancements: +```yaml +pr_comment_links_enabled: 'false' +pr_comment_collapse_enabled: 'false' +pr_comment_code_fencing_enabled: 'false' +pr_comment_show_rule_names: 'false' +pr_labels_enabled: 'false' +``` + +### Enterprise (Custom Labels) + +Match your organization's label taxonomy: +```yaml +pr_label_critical: 'vulnerability: critical' +pr_label_high: 'vulnerability: high' +pr_label_medium: 'vulnerability: medium' +``` + +### Security Team (All Expanded) + +Show all details expanded for thorough review: +```yaml +pr_comment_collapse_non_critical: 'false' +``` + +### OSS Project (Minimize Noise) + +Keep comments clean and collapsed: +```yaml +pr_comment_collapse_non_critical: 'true' +pr_label_critical: 'security' +pr_label_high: 'security' +pr_label_medium: 'security' +``` + +--- + +## ๐Ÿš€ Migration Guide + +### Already using Socket Basics? + +**Good news!** All new features are **opt-out** with sensible defaults. + +**Your existing workflows will automatically benefit from:** +- โœ… Clickable file links +- โœ… Collapsible sections +- โœ… Syntax highlighting +- โœ… Rule names +- โœ… Auto-labels + +**No changes required** unless you want to customize behavior. + +### Disable specific features + +If you prefer the old style, simply disable individual features: + +```yaml +# Keep everything except labels +pr_labels_enabled: 'false' + +# Or disable everything +pr_comment_links_enabled: 'false' +pr_comment_collapse_enabled: 'false' +pr_comment_code_fencing_enabled: 'false' +pr_comment_show_rule_names: 'false' +pr_labels_enabled: 'false' +``` + +--- + +## ๐Ÿ’ก Tips & Best Practices + +### For Open Source Projects +- Use simple label taxonomy (e.g., all `security`) +- Keep non-critical findings collapsed +- Enable all visual enhancements for contributor UX + +### For Enterprise Teams +- Match your organization's label taxonomy +- Consider expanding all findings for security review +- Customize labels to integrate with existing workflows + +### For Security Teams +- Expand all findings by default +- Enable all enhancements for maximum detail +- Use specific label names for automation + +--- + +## ๐Ÿ—๏ธ Architecture & Extensibility + +### Centralized PR Comment Logic + +All PR comment enhancements are powered by a **shared helper module** at: +``` +socket_basics/core/notification/github_pr_helpers.py +``` + +This centralized approach provides: +- **Consistent UX** across all scanner types +- **Zero code duplication** - one implementation for all formatters +- **Easy integration** for new OSS security tools + +### Adding Your Own Security Tool + +Socket Basics is designed to support **any security scanner**. To add enhancements to a new tool: + +```python +from socket_basics.core.notification import github_pr_helpers as helpers + +def format_notifications(data, config=None): + # 1. Get feature flags (handles all config sources automatically) + flags = helpers.get_feature_flags(config) + + # 2. Use shared utilities to build your content + file_link = helpers.format_file_location_link( + filepath, line_start=line_num, repository=flags['repository'], + commit_hash=flags['commit_hash'], enable_links=flags['enable_links'] + ) + + code_block = helpers.format_code_block( + code_snippet, filepath=filepath, + enable_fencing=flags['enable_code_fencing'] + ) + + collapsible = helpers.create_collapsible_section( + title, content, severity_counts, + auto_expand=(critical and not flags['collapse_non_critical']) + ) + + # 3. Wrap in the standard PR comment section (logo, scan link, markers) + wrapped = helpers.wrap_pr_comment_section( + 'my-scanner', title, body_content, flags['full_scan_url'] + ) + + return [{'title': title, 'content': wrapped}] +``` + +**That's it!** Your tool instantly gets: +- โœ… Clickable file/line links +- โœ… Collapsible sections +- โœ… Syntax highlighting +- โœ… Logo branding and full scan report link +- โœ… Idempotent comment updates (via HTML markers) +- โœ… Configuration management + +### Shared Constants + +Use shared severity constants for consistency: +```python +severity_order = helpers.SEVERITY_ORDER # {'critical': 0, 'high': 1, ...} +severity_emoji = helpers.SEVERITY_EMOJI # {'critical': '๐Ÿ”ด', 'high': '๐ŸŸ ', ...} +``` + +--- + +## ๐Ÿ“š Related Documentation + +- [Main README](../README.md) +- [GitHub Actions Integration](github-action.md) +- [Configuration Examples](../socket_config_example.json) +- [Shared Helper Module](../socket_basics/core/notification/github_pr_helpers.py) diff --git a/docs/local-install-docker.md b/docs/local-install-docker.md index 02b3c69..b2ffb99 100644 --- a/docs/local-install-docker.md +++ b/docs/local-install-docker.md @@ -335,6 +335,10 @@ done ### CI/CD Integration +> **Using GitHub Actions?** Socket Basics has first-class GitHub Actions support with automatic PR comments, labels, and more โ€” no Docker setup needed. See the [Quick Start](../README.md#-quick-start---github-actions) or the [GitHub Actions Guide](github-action.md). + +For other CI/CD platforms, use the Docker image directly: + **Example: Jenkins** ```groovy diff --git a/docs/local-installation.md b/docs/local-installation.md index 7cd3763..d36ff0a 100644 --- a/docs/local-installation.md +++ b/docs/local-installation.md @@ -537,6 +537,10 @@ socket-basics \ --verbose ``` +### CI/CD Integration + +> **Using GitHub Actions?** Socket Basics has first-class GitHub Actions support with automatic PR comments, labels, and more โ€” no local installation needed. See the [Quick Start](../README.md#-quick-start---github-actions) or the [GitHub Actions Guide](github-action.md). + ### Continuous Scanning Watch for file changes and re-scan: diff --git a/socket_basics/core/connector/opengrep/__init__.py b/socket_basics/core/connector/opengrep/__init__.py index 07ccb70..8be357a 100644 --- a/socket_basics/core/connector/opengrep/__init__.py +++ b/socket_basics/core/connector/opengrep/__init__.py @@ -558,7 +558,7 @@ def generate_notifications(self, components: List[Dict[str, Any]]) -> Dict[str, # Build notifications for each notifier type using OpenGrep-specific modules notifications_by_notifier = {} - notifications_by_notifier['github_pr'] = github_pr.format_notifications(groups) + notifications_by_notifier['github_pr'] = github_pr.format_notifications(groups, config=self.config) notifications_by_notifier['slack'] = slack.format_notifications(groups) notifications_by_notifier['msteams'] = ms_teams.format_notifications(groups) notifications_by_notifier['ms_sentinel'] = ms_sentinel.format_notifications(groups) diff --git a/socket_basics/core/connector/opengrep/github_pr.py b/socket_basics/core/connector/opengrep/github_pr.py index d523fab..be3c247 100644 --- a/socket_basics/core/connector/opengrep/github_pr.py +++ b/socket_basics/core/connector/opengrep/github_pr.py @@ -8,6 +8,7 @@ from typing import Dict, Any, List import logging import yaml +from socket_basics.core.notification import github_pr_helpers as helpers logger = logging.getLogger(__name__) @@ -27,11 +28,22 @@ def _get_github_pr_result_limit() -> int: def format_notifications(groups: Dict[str, List[Dict[str, Any]]], config=None) -> List[Dict[str, Any]]: """Format for GitHub PR comments - detailed with markdown formatting.""" tables = [] - + + # Get feature flags from config (using shared helper) + flags = helpers.get_feature_flags(config) + enable_links = flags['enable_links'] + enable_collapse = flags['enable_collapse'] + collapse_non_critical = flags['collapse_non_critical'] + enable_code_fencing = flags['enable_code_fencing'] + show_rule_names = flags['show_rule_names'] + repository = flags['repository'] + commit_hash = flags['commit_hash'] + full_scan_url = flags['full_scan_url'] + # Map subtypes to friendly display names subtype_names = { 'sast-python': 'Socket SAST Python', - 'sast-javascript': 'Socket SAST JavaScript', + 'sast-javascript': 'Socket SAST JavaScript', 'sast-golang': 'Socket SAST Go', 'sast-java': 'Socket SAST Java', 'sast-php': 'Socket SAST PHP', @@ -45,14 +57,10 @@ def format_notifications(groups: Dict[str, List[Dict[str, Any]]], config=None) - 'sast-swift': 'Socket SAST Swift', 'sast-rust': 'Socket SAST Rust', } - - severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3} - severity_emoji = { - 'critical': '๐Ÿ”ด', - 'high': '๐ŸŸ ', - 'medium': '๐ŸŸก', - 'low': 'โšช' - } + + # Use shared severity constants + severity_order = helpers.SEVERITY_ORDER + severity_emoji = helpers.SEVERITY_EMOJI for subtype, items in groups.items(): # Group findings by file path, then by rule within each file @@ -127,60 +135,93 @@ def format_notifications(groups: Dict[str, List[Dict[str, Any]]], config=None) - file_name = Path(file_path).name except Exception: file_name = file_path - - # File header - content_lines.append(f"#### `{file_path}`") - content_lines.append("") - + + # Calculate severity counts for this file (for collapsible summary) + file_severities = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} + for rule_id, locations in file_groups[file_path].items(): + for loc in locations: + sev = loc['severity'] + if sev in file_severities: + file_severities[sev] += 1 + # Sort rules by severity within file rules_in_file = [] for rule_id, locations in file_groups[file_path].items(): # Get highest severity for this rule min_severity = min(severity_order.get(loc['severity'], 4) for loc in locations) rules_in_file.append((min_severity, rule_id, locations)) - + rules_in_file.sort(key=lambda x: x[0]) - + + # Build file section content + file_content_lines = [] + # Output each rule with its locations for _, rule_id, locations in rules_in_file: # Get severity from first location (they should all be same rule) rule_severity = locations[0]['severity'] emoji = severity_emoji.get(rule_severity, 'โšช') - - content_lines.append(f"**{rule_id}** ") - content_lines.append(f"{emoji} *{rule_severity.upper()}*") - content_lines.append("") - + + # Show rule name + if show_rule_names: + file_content_lines.append(f"{emoji} **{rule_id}**: *{rule_severity.upper()}*") + else: + file_content_lines.append(f"{emoji} *{rule_severity.upper()}*") + file_content_lines.append("") + # Output each location with code snippet for loc in locations: - content_lines.append(f"**Lines {loc['start_line']}:{loc['end_line']}**") + # Create clickable file location link + location_link = helpers.format_file_location_link( + file_path, + loc['start_line'], + loc['end_line'], + repository, + commit_hash, + enable_links + ) + file_content_lines.append(location_link) + if loc['code_snippet']: - # Format code snippet in code block - content_lines.append("```") - content_lines.append(loc['code_snippet']) - content_lines.append("```") - content_lines.append("") + # Format code snippet with language-aware fencing + code_block = helpers.format_code_block( + loc['code_snippet'], + filepath=file_path, + enable_fencing=enable_code_fencing + ) + file_content_lines.append(code_block) + file_content_lines.append("") + + file_content = '\n'.join(file_content_lines) + + # Add collapsible section or plain header + if enable_collapse: + # Determine if this should be auto-expanded + has_critical = file_severities['critical'] > 0 + # Auto-expand if: no collapse requested OR has critical findings + auto_expand = (not collapse_non_critical) or has_critical + + collapsible = helpers.create_collapsible_section( + file_path, # Don't use backticks in summary - they don't render in GitHub + file_content, + severity_counts=file_severities, + auto_expand=auto_expand + ) + content_lines.append(collapsible) + else: + content_lines.append(f"#### `{file_path}`") + content_lines.append("") + content_lines.append(file_content) content = '\n'.join(content_lines) - # Build title - title_parts = [display_name] - if config: - if config.repo: - title_parts.append(config.repo) - if config.branch: - title_parts.append(config.branch) - if config.commit_hash: - title_parts.append(config.commit_hash[:8]) # Short hash - - title = " - ".join(title_parts) - - # Wrap content with HTML comment markers for section updates - wrapped_content = f""" -# {title} + # Build title - just scanner name (repo/branch context already visible in PR) + title = display_name -{content} -""" + # Wrap in standard PR comment section + wrapped_content = helpers.wrap_pr_comment_section( + subtype, title, content, full_scan_url + ) tables.append({ 'title': title, diff --git a/socket_basics/core/connector/socket_tier1/github_helpers.py b/socket_basics/core/connector/socket_tier1/github_helpers.py new file mode 100644 index 0000000..5e3dda0 --- /dev/null +++ b/socket_basics/core/connector/socket_tier1/github_helpers.py @@ -0,0 +1,197 @@ +"""Helper functions for GitHub PR comment formatting.""" + +import re +from typing import Dict, Any, Optional, List +from pathlib import Path + + +def detect_language_from_filename(filename: str) -> str: + """Detect programming language from file extension. + + Args: + filename: File path or name + + Returns: + Markdown language identifier for code fencing + """ + ext_map = { + '.js': 'javascript', + '.jsx': 'javascript', + '.ts': 'typescript', + '.tsx': 'typescript', + '.py': 'python', + '.go': 'go', + '.java': 'java', + '.kt': 'kotlin', + '.scala': 'scala', + '.rb': 'ruby', + '.php': 'php', + '.cs': 'csharp', + '.cpp': 'cpp', + '.c': 'c', + '.h': 'c', + '.hpp': 'cpp', + '.swift': 'swift', + '.rs': 'rust', + '.ex': 'elixir', + '.exs': 'elixir', + '.erl': 'erlang', + '.sh': 'bash', + '.yaml': 'yaml', + '.yml': 'yaml', + '.json': 'json', + '.xml': 'xml', + '.html': 'html', + '.css': 'css', + '.sql': 'sql', + } + + ext = Path(filename).suffix.lower() + return ext_map.get(ext, '') + + +def build_github_file_url( + repository: str, + commit_hash: str, + filepath: str, + line_start: Optional[int] = None, + line_end: Optional[int] = None +) -> str: + """Build a GitHub URL to a specific file and line range. + + Args: + repository: GitHub repository (e.g., "owner/repo") + commit_hash: Git commit hash + filepath: Relative file path from repository root + line_start: Starting line number (optional) + line_end: Ending line number (optional) + + Returns: + GitHub URL string + """ + if not repository or not commit_hash: + return '' + + # Clean filepath (remove leading ./ or /) + clean_path = filepath.lstrip('./') + + # Base URL + url = f"https://github.com/{repository}/blob/{commit_hash}/{clean_path}" + + # Add line anchor + if line_start is not None: + if line_end is not None and line_end != line_start: + url += f"#L{line_start}-L{line_end}" + else: + url += f"#L{line_start}" + + return url + + +def format_trace_with_links( + trace_lines: List[str], + repository: str, + commit_hash: str, + enable_links: bool = True +) -> str: + """Format trace lines with clickable GitHub links. + + Args: + trace_lines: List of trace strings (format: "package - filename.ext 10:5-15:20") + repository: GitHub repository + commit_hash: Git commit hash + enable_links: Whether to create clickable links + + Returns: + Formatted trace string with optional links + """ + if not trace_lines: + return '' + + formatted_lines = [] + + for line in trace_lines: + if not enable_links: + formatted_lines.append(line) + continue + + # Parse trace format: "package_name - filename.js 72:12-75:6" + # or " -> module_name path/to/file.py 45:2" + # Note: package names can contain dashes, so we look for " - " (space-dash-space) as separator + + # Try format with " - " separator first + match = re.match( + r'^(\s*)(.+?)\s+-\s+([^\s]+)\s+(\d+):(\d+)(?:-(\d+):(\d+))?$', + line + ) + + if not match: + # Try format with "-> " prefix (no " - " separator) + match = re.match( + r'^(\s+->)\s+(.+?)\s+([^\s]+)\s+(\d+):(\d+)(?:-(\d+):(\d+))?$', + line + ) + + if match: + prefix = match.group(1) + package = match.group(2).strip() + filename = match.group(3) + line_start = int(match.group(4)) + col_start = match.group(5) + line_end = int(match.group(6)) if match.group(6) else line_start + col_end = match.group(7) + + # Build GitHub URL + github_url = build_github_file_url( + repository, commit_hash, filename, line_start, line_end + ) + + if github_url: + # Create markdown link + location = f"{line_start}:{col_start}" + if line_end != line_start or (col_end and col_end != col_start): + location += f"-{line_end}:{col_end}" + + # Format based on whether it has the " - " separator or "-> " prefix + if ' - ' in line or line.strip().startswith('->'): + # Preserve original structure + if '-> ' in prefix: + formatted = f"{prefix} {package} [{filename} {location}]({github_url})" + else: + formatted = f"{prefix}{package} - [{filename} {location}]({github_url})" + else: + formatted = f"{prefix}{package} - [{filename} {location}]({github_url})" + + formatted_lines.append(formatted) + else: + formatted_lines.append(line) + else: + # Couldn't parse, keep original + formatted_lines.append(line) + + return '\n'.join(formatted_lines) + + +def extract_rule_name(alert_data: Dict[str, Any]) -> str: + """Extract rule name from alert data. + + Args: + alert_data: Alert dictionary + + Returns: + Rule name or empty string + """ + # Check various possible sources + props = alert_data.get('props', {}) or {} + + # Priority order for rule names + rule_name = ( + props.get('rule') or + props.get('rule_name') or + props.get('ruleName') or + alert_data.get('rule') or + alert_data.get('type') or + '' + ) + + return str(rule_name) if rule_name else '' diff --git a/socket_basics/core/connector/socket_tier1/github_pr.py b/socket_basics/core/connector/socket_tier1/github_pr.py index e10f967..0e33741 100644 --- a/socket_basics/core/connector/socket_tier1/github_pr.py +++ b/socket_basics/core/connector/socket_tier1/github_pr.py @@ -1,6 +1,8 @@ """GitHub PR notifier formatting for Socket Tier1 reachability analysis.""" +import re from typing import Dict, Any, List +from socket_basics.core.notification import github_pr_helpers as helpers def _make_purl(comp: Dict[str, Any]) -> str: @@ -26,14 +28,21 @@ def _make_purl(comp: Dict[str, Any]) -> str: def format_notifications(components_list: List[Dict[str, Any]], config=None) -> List[Dict[str, Any]]: """Format for GitHub PR comments - grouped by PURL and reachability.""" from collections import defaultdict - - severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3} - severity_emoji = { - 'critical': '๐Ÿ”ด', - 'high': '๐ŸŸ ', - 'medium': '๐ŸŸก', - 'low': 'โšช' - } + + # Get feature flags from config (using shared helper) + flags = helpers.get_feature_flags(config) + enable_links = flags['enable_links'] + enable_collapse = flags['enable_collapse'] + collapse_non_critical = flags['collapse_non_critical'] + enable_code_fencing = flags['enable_code_fencing'] + show_rule_names = flags['show_rule_names'] + repository = flags['repository'] + commit_hash = flags['commit_hash'] + full_scan_url = flags['full_scan_url'] + + # Use shared severity constants + severity_order = helpers.SEVERITY_ORDER + severity_emoji = helpers.SEVERITY_EMOJI # Group by PURL -> Reachability -> Findings purl_groups = defaultdict(lambda: {'reachable': [], 'unknown': [], 'error': [], 'unreachable': []}) @@ -48,11 +57,19 @@ def format_notifications(components_list: List[Dict[str, Any]], config=None) -> cve_id = str(props.get('ghsaId') or props.get('cveId') or a.get('title') or '') severity = str(a.get('severity') or props.get('severity') or '').lower() reachability = str(props.get('reachability') or 'unknown').lower() - + + # Get CVSS score if available + cvss_score = None + if 'cvssScore' in props: + try: + cvss_score = float(props['cvssScore']) + except (ValueError, TypeError): + pass + # Count by severity if severity in severity_counts: severity_counts[severity] += 1 - + # Get trace data trace_raw = props.get('trace') or '' trace_str = '' @@ -60,16 +77,18 @@ def format_notifications(components_list: List[Dict[str, Any]], config=None) -> trace_str = '\n'.join(str(x) for x in trace_raw) elif isinstance(trace_raw, str): trace_str = trace_raw - + # Truncate long traces if trace_str and len(trace_str) > 500: trace_str = trace_str[:500] + '\n...' - + finding = { 'cve_id': cve_id, 'severity': severity, 'severity_order': severity_order.get(severity, 4), - 'trace': trace_str + 'trace': trace_str, + 'rule_name': a.get('title') or cve_id, + 'cvss_score': cvss_score } # Group by reachability @@ -109,20 +128,80 @@ def format_notifications(components_list: List[Dict[str, Any]], config=None) -> purl_severity_list.sort(key=lambda x: x[0]) - for _, purl in purl_severity_list: - content_lines.append(f"#### `{purl}`") - content_lines.append("") - + for min_sev, purl in purl_severity_list: + # Calculate severity summary for this PURL + purl_severities = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} + for reach_type in ['reachable', 'unknown', 'error', 'unreachable']: + for finding in purl_groups[purl][reach_type]: + sev = finding['severity'] + if sev in purl_severities: + purl_severities[sev] += 1 + + # Determine if this section should be auto-expanded + has_critical = purl_severities['critical'] > 0 + + if enable_collapse: + # Build severity summary for section header + severity_parts = [] + if purl_severities['critical'] > 0: + severity_parts.append(f"{severity_emoji['critical']} Critical: {purl_severities['critical']}") + if purl_severities['high'] > 0: + severity_parts.append(f"{severity_emoji['high']} High: {purl_severities['high']}") + if purl_severities['medium'] > 0: + severity_parts.append(f"{severity_emoji['medium']} Medium: {purl_severities['medium']}") + if purl_severities['low'] > 0: + severity_parts.append(f"{severity_emoji['low']} Low: {purl_severities['low']}") + + severity_summary = " | ".join(severity_parts) if severity_parts else "No issues" + + open_attr = ' open' if (not collapse_non_critical or has_critical) else '' + content_lines.append(f"") + content_lines.append(f"{purl} ({severity_summary})") + content_lines.append("") + else: + content_lines.append(f"#### `{purl}`") + content_lines.append("") + # Reachable findings (highest priority) if purl_groups[purl]['reachable']: content_lines.append("**Reachable**") content_lines.append("") for finding in purl_groups[purl]['reachable']: - emoji = severity_emoji.get(finding['severity'], 'โšช') - content_lines.append(f"{emoji} **{finding['cve_id']}**: *{finding['severity'].upper()}*") + # Use new vulnerability header format with CVE link and CVSS score + header = helpers.format_vulnerability_header( + finding['cve_id'], + finding['severity'], + finding.get('cvss_score') + ) + content_lines.append(header) + + # Add rule name if enabled and different from CVE ID + if show_rule_names: + rule_name = finding.get('rule_name', '') + if rule_name and rule_name != finding['cve_id']: + content_lines.append(f"**Rule**: `{rule_name}`") + if finding['trace']: - content_lines.append("```") - content_lines.append(finding['trace']) + trace_str = finding['trace'] + + # Add clickable links + if enable_links and repository and commit_hash: + trace_lines = trace_str.split('\n') + trace_str = helpers.format_trace_with_links( + trace_lines, repository, commit_hash, enable_links + ) + + # Language-aware code fencing + lang = '' + if enable_code_fencing: + # Detect from first filename in trace + first_line = trace_str.split('\n')[0] if trace_str else '' + match = re.search(r'([^\s]+\.[\w]+)', first_line) + if match: + lang = helpers.detect_language_from_filename(match.group(1)) + + content_lines.append(f"```{lang}") + content_lines.append(trace_str) content_lines.append("```") content_lines.append("") @@ -131,54 +210,81 @@ def format_notifications(components_list: List[Dict[str, Any]], config=None) -> content_lines.append("**Unknown**") content_lines.append("") for finding in purl_groups[purl]['unknown']: - emoji = severity_emoji.get(finding['severity'], 'โšช') - content_lines.append(f"{emoji} **{finding['cve_id']}**: *{finding['severity'].upper()}*") + # Use new vulnerability header format + header = helpers.format_vulnerability_header( + finding['cve_id'], + finding['severity'], + finding.get('cvss_score') + ) + content_lines.append(header) + + # Add rule name if enabled and different from CVE ID + if show_rule_names: + rule_name = finding.get('rule_name', '') + if rule_name and rule_name != finding['cve_id']: + content_lines.append(f"**Rule**: `{rule_name}`") content_lines.append("") - + # Error reachability findings if purl_groups[purl]['error']: content_lines.append("**Error**") content_lines.append("") for finding in purl_groups[purl]['error']: - emoji = severity_emoji.get(finding['severity'], 'โšช') - content_lines.append(f"{emoji} **{finding['cve_id']}**: *{finding['severity'].upper()}*") + # Use new vulnerability header format + header = helpers.format_vulnerability_header( + finding['cve_id'], + finding['severity'], + finding.get('cvss_score') + ) + content_lines.append(header) + + # Add rule name if enabled and different from CVE ID + if show_rule_names: + rule_name = finding.get('rule_name', '') + if rule_name and rule_name != finding['cve_id']: + content_lines.append(f"**Rule**: `{rule_name}`") content_lines.append("") - + # Unreachable findings (lowest priority) if purl_groups[purl]['unreachable']: content_lines.append("**Unreachable**") content_lines.append("") for finding in purl_groups[purl]['unreachable']: - emoji = severity_emoji.get(finding['severity'], 'โšช') - content_lines.append(f"{emoji} **{finding['cve_id']}**: *{finding['severity'].upper()}*") + # Use new vulnerability header format + header = helpers.format_vulnerability_header( + finding['cve_id'], + finding['severity'], + finding.get('cvss_score') + ) + content_lines.append(header) + + # Add rule name if enabled and different from CVE ID + if show_rule_names: + rule_name = finding.get('rule_name', '') + if rule_name and rule_name != finding['cve_id']: + content_lines.append(f"**Rule**: `{rule_name}`") + content_lines.append("") + + # Close collapsible section + if enable_collapse: + content_lines.append("") content_lines.append("") content = '\n'.join(content_lines) - # Build title with repo/branch/commit info from config - title_parts = ["Socket Security Tier 1 Results"] - if config: - if config.repo: - title_parts.append(config.repo) - if config.branch: - title_parts.append(config.branch) - if config.commit_hash: - title_parts.append(config.commit_hash) - - title = " - ".join(title_parts) - + # Build title - just scanner name (repo/branch context already visible in PR) + title = "Socket Security Tier 1" + # Count total findings total_findings = sum(severity_counts.values()) - + # Content already includes summary and details sections summary_content = content - - # Wrap content with HTML comment markers for section updates - wrapped_content = f""" -# {title} -{summary_content} -""" + # Wrap in standard PR comment section + wrapped_content = helpers.wrap_pr_comment_section( + 'socket-tier1', title, summary_content, full_scan_url + ) return [{ 'title': title, diff --git a/socket_basics/core/connector/socket_tier1/scanner.py b/socket_basics/core/connector/socket_tier1/scanner.py index 12f8b9a..ca57a53 100644 --- a/socket_basics/core/connector/socket_tier1/scanner.py +++ b/socket_basics/core/connector/socket_tier1/scanner.py @@ -598,7 +598,10 @@ def generate_notifications(self, components: List[Dict[str, Any]]) -> Dict[str, # Build notifications for each notifier type using Socket Tier1-specific modules notifications_by_notifier = {} - notifications_by_notifier['github_pr'] = github_pr.format_notifications(filtered_components) + notifications_by_notifier['github_pr'] = github_pr.format_notifications( + filtered_components, + config=self.config # Pass config for repository metadata and feature flags + ) notifications_by_notifier['slack'] = slack.format_notifications(filtered_components) notifications_by_notifier['msteams'] = ms_teams.format_notifications(filtered_components) notifications_by_notifier['ms_sentinel'] = ms_sentinel.format_notifications(filtered_components) diff --git a/socket_basics/core/connector/trivy/github_pr.py b/socket_basics/core/connector/trivy/github_pr.py index fcee974..22ac274 100644 --- a/socket_basics/core/connector/trivy/github_pr.py +++ b/socket_basics/core/connector/trivy/github_pr.py @@ -7,16 +7,29 @@ from typing import Dict, Any, List from collections import defaultdict from .utils import logger, get_notifier_result_limit +from socket_basics.core.notification import github_pr_helpers as helpers -def format_notifications(mapping: Dict[str, Any], item_name: str = "Unknown", scan_type: str = "image") -> List[Dict[str, Any]]: +def format_notifications(mapping: Dict[str, Any], item_name: str = "Unknown", scan_type: str = "image", config=None) -> List[Dict[str, Any]]: """Format for GitHub PR comments - grouped format with markdown formatting. - + Args: mapping: Component mapping with alerts item_name: Name of the scanned item scan_type: Type of scan - 'vuln', 'image', or 'dockerfile' + config: Optional configuration object with feature flags """ + # Get feature flags from config (using shared helper) + flags = helpers.get_feature_flags(config) + enable_links = flags['enable_links'] + enable_collapse = flags['enable_collapse'] + collapse_non_critical = flags['collapse_non_critical'] + enable_code_fencing = flags['enable_code_fencing'] + show_rule_names = flags['show_rule_names'] + repository = flags['repository'] + commit_hash = flags['commit_hash'] + full_scan_url = flags['full_scan_url'] + # Group vulnerabilities by package and severity package_groups = defaultdict(lambda: defaultdict(set)) # Use set to avoid duplicates @@ -63,7 +76,8 @@ def format_notifications(mapping: Dict[str, Any], item_name: str = "Unknown", sc # Create rows with proper formatting rows = [] - severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3} + severity_order = helpers.SEVERITY_ORDER + severity_emoji = helpers.SEVERITY_EMOJI if scan_type == 'dockerfile': # Dockerfile format: Rule ID | Severity | Message | Resolution @@ -118,17 +132,25 @@ def format_notifications(mapping: Dict[str, Any], item_name: str = "Unknown", sc cve_id = str(props.get('vulnerabilityId', '')) severity = str(alert.get('severity', '')).lower() description = str(alert.get('description', 'No description available')) - + + # Get CVSS score if available + cvss_score = None + if 'cvssScore' in props: + try: + cvss_score = float(props['cvssScore']) + except (ValueError, TypeError): + pass + # Build package identifier if comp_version: package = f"pkg:{ecosystem}/{comp_name}@{comp_version}" else: package = f"pkg:{ecosystem}/{comp_name}" - + # Get additional metadata fixed_version = str(props.get('fixedVersion', 'Not available')) installed_version = comp_version or 'Unknown' - + vuln_details.append({ 'cve_id': cve_id, 'severity': severity, @@ -138,7 +160,8 @@ def format_notifications(mapping: Dict[str, Any], item_name: str = "Unknown", sc 'ecosystem': ecosystem, 'installed_version': installed_version, 'fixed_version': fixed_version, - 'description': description + 'description': description, + 'cvss_score': cvss_score }) # Sort by severity @@ -160,17 +183,25 @@ def format_notifications(mapping: Dict[str, Any], item_name: str = "Unknown", sc cve_id = str(props.get('vulnerabilityId', '')) severity = str(alert.get('severity', '')).lower() description = str(alert.get('description', 'No description available')) - + + # Get CVSS score if available + cvss_score = None + if 'cvssScore' in props: + try: + cvss_score = float(props['cvssScore']) + except (ValueError, TypeError): + pass + # Build package identifier if comp_version: package = f"pkg:{ecosystem}/{comp_name}@{comp_version}" else: package = f"pkg:{ecosystem}/{comp_name}" - + # Get additional metadata fixed_version = str(props.get('fixedVersion', 'Not available')) installed_version = comp_version or 'Unknown' - + vuln_details.append({ 'cve_id': cve_id, 'severity': severity, @@ -180,7 +211,8 @@ def format_notifications(mapping: Dict[str, Any], item_name: str = "Unknown", sc 'ecosystem': ecosystem, 'installed_version': installed_version, 'fixed_version': fixed_version, - 'description': description + 'description': description, + 'cvss_score': cvss_score }) # Sort by severity @@ -208,21 +240,17 @@ def format_notifications(mapping: Dict[str, Any], item_name: str = "Unknown", sc # Panel format for vulnerability scanning panels = [] for vuln in rows: - # Determine panel color based on severity - severity_icons = { - 'critical': '๐Ÿ”ด', - 'high': '๐ŸŸ ', - 'medium': '๐ŸŸก', - 'low': '๐ŸŸข' - } - icon = severity_icons.get(vuln['severity'], 'โšช') - severity_label = vuln['severity'].upper() - + # Create vulnerability header with CVE link and CVSS score + vuln_header = helpers.format_vulnerability_header( + vuln['cve_id'], + vuln['severity'], + vuln.get('cvss_score') + ) + # Create expandable panel for each CVE panel = f"""
-{icon} {vuln['cve_id']} +{vuln_header} -**Severity:** {severity_label} **Package:** `{vuln['package']}` @@ -262,37 +290,33 @@ def format_notifications(mapping: Dict[str, Any], item_name: str = "Unknown", sc # Build title based on scan type if scan_type == 'vuln': - title_base = "Socket CVE Scanning Results" + title = "Socket CVE Scanning" scanner_name = "Trivy Vuln Scanning" elif scan_type == 'dockerfile': - title_base = "Socket Dockerfile Results" + title = "Socket Dockerfile Scan" scanner_name = "Trivy Dockerfile" else: # image - title_base = "Socket Image Scanning Results" + title = "Socket Container Scan" scanner_name = "Trivy Container" - - title = f"{title_base}: {item_name}" - + # Count total findings for summary total_findings = len(rows) - + # Add summary section with scanner findings - summary_content = f"""## Summary + summary_content = f"""### Summary | Scanner | Findings | |---------|----------| | {scanner_name} | {total_findings} | -## Details +### Details {content}""" - - # Wrap content with HTML comment markers for section updates - wrapped_content = f""" -# {title} -{summary_content} -""" + # Wrap in standard PR comment section + wrapped_content = helpers.wrap_pr_comment_section( + 'trivy-container', title, summary_content, full_scan_url + ) return [{ 'title': title, diff --git a/socket_basics/core/connector/trivy/trivy.py b/socket_basics/core/connector/trivy/trivy.py index a80af01..c4f518b 100644 --- a/socket_basics/core/connector/trivy/trivy.py +++ b/socket_basics/core/connector/trivy/trivy.py @@ -1082,7 +1082,7 @@ def generate_notifications(self, components: List[Dict[str, Any]], item_name: st # Build notifications for each notifier type using Trivy-specific modules notifications_by_notifier = {} - notifications_by_notifier['github_pr'] = github_pr.format_notifications(comps_map, item_name, scan_type) + notifications_by_notifier['github_pr'] = github_pr.format_notifications(comps_map, item_name, scan_type, config=self.config) notifications_by_notifier['slack'] = slack.format_notifications(comps_map, item_name, scan_type) notifications_by_notifier['msteams'] = ms_teams.format_notifications(comps_map, item_name, scan_type) notifications_by_notifier['ms_sentinel'] = ms_sentinel.format_notifications(comps_map, item_name, scan_type) diff --git a/socket_basics/core/connector/trufflehog/__init__.py b/socket_basics/core/connector/trufflehog/__init__.py index 9cba07d..4ff6ccf 100644 --- a/socket_basics/core/connector/trufflehog/__init__.py +++ b/socket_basics/core/connector/trufflehog/__init__.py @@ -461,7 +461,7 @@ def generate_notifications(self, components: List[Dict[str, Any]]) -> Dict[str, # Build notifications for each notifier type using TruffleHog-specific modules notifications_by_notifier = {} - notifications_by_notifier['github_pr'] = github_pr.format_notifications(filtered_comps_map) + notifications_by_notifier['github_pr'] = github_pr.format_notifications(filtered_comps_map, config=self.config) notifications_by_notifier['slack'] = slack.format_notifications(filtered_comps_map) notifications_by_notifier['msteams'] = ms_teams.format_notifications(filtered_comps_map) notifications_by_notifier['ms_sentinel'] = ms_sentinel.format_notifications(filtered_comps_map) diff --git a/socket_basics/core/connector/trufflehog/github_pr.py b/socket_basics/core/connector/trufflehog/github_pr.py index 959d807..58b2f19 100644 --- a/socket_basics/core/connector/trufflehog/github_pr.py +++ b/socket_basics/core/connector/trufflehog/github_pr.py @@ -5,11 +5,22 @@ """ from typing import Dict, Any, List +from socket_basics.core.notification import github_pr_helpers as helpers def format_notifications(mapping: Dict[str, Any], config=None) -> List[Dict[str, Any]]: """Format for GitHub PR comments - detailed with markdown formatting.""" - severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3} + # Get feature flags from config (using shared helper) + flags = helpers.get_feature_flags(config) + enable_links = flags['enable_links'] + repository = flags['repository'] + commit_hash = flags['commit_hash'] + full_scan_url = flags['full_scan_url'] + + # Use shared severity constants + severity_order = helpers.SEVERITY_ORDER + severity_emoji = helpers.SEVERITY_EMOJI + rows = [] for comp in mapping.values(): @@ -24,15 +35,25 @@ def format_notifications(mapping: Dict[str, Any], config=None) -> List[Dict[str, # Format with markdown for better GitHub display status = 'โœ… **VERIFIED**' if verified else 'โš ๏ธ *Unverified*' - file_display = f"`{file_path}`" - if line: - file_display += f":{line}" - + + # Create clickable file location link + line_num = int(line) if line and line.isdigit() else None + file_display = helpers.format_file_location_link( + file_path, + line_start=line_num, + repository=repository, + commit_hash=commit_hash, + enable_links=enable_links + ) + + # Add severity emoji + emoji = severity_emoji.get(severity, 'โšช') + rows.append(( severity_order.get(severity, 4), [ f"**{detector}**", - f"*{severity.upper()}*", + f"{emoji} *{severity.upper()}*", status, file_display, f"`{redacted}`" if redacted else '-' @@ -56,38 +77,27 @@ def format_notifications(mapping: Dict[str, Any], config=None) -> List[Dict[str, content = '\n'.join([header_row, separator_row] + content_rows) - # Build title with repo/branch/commit info from config - title_parts = ["Socket Security Results"] - if config: - if config.repo: - title_parts.append(config.repo) - if config.branch: - title_parts.append(config.branch) - if config.commit_hash: - title_parts.append(config.commit_hash) - - title = " - ".join(title_parts) - + # Build title - just scanner name (repo/branch context already visible in PR) + title = "Socket Secret Scanning" + # Count total findings for summary total_findings = len(rows) - + # Add summary section with scanner findings - summary_content = f"""## Summary + summary_content = f"""### Summary | Scanner | Findings | |---------|----------| | TruffleHog Secrets | {total_findings} | -## Details +### Details {content}""" - - # Wrap content with HTML comment markers for section updates - wrapped_content = f""" -# {title} -{summary_content} -""" + # Wrap in standard PR comment section + wrapped_content = helpers.wrap_pr_comment_section( + 'trufflehog-secrets', title, summary_content, full_scan_url + ) return [{ 'title': title, diff --git a/socket_basics/core/notification/github_pr_helpers.py b/socket_basics/core/notification/github_pr_helpers.py new file mode 100644 index 0000000..42c95ab --- /dev/null +++ b/socket_basics/core/notification/github_pr_helpers.py @@ -0,0 +1,577 @@ +"""Shared helper functions for GitHub PR comment formatting across all scanners. + +This module provides centralized utilities for: +- Clickable file/line links +- Collapsible sections with severity summaries +- Language-aware code fencing +- Rule name extraction +- Configuration-based feature flags + +These utilities are designed to work with any security scanner (SAST, SCA, secrets, containers). +""" + +import re +from typing import Dict, Any, Optional, List, Tuple +from pathlib import Path + + +# ============================================================================ +# Severity Constants (shared across all scanners) +# ============================================================================ + +SEVERITY_ORDER = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3} + +SEVERITY_EMOJI = { + 'critical': '๐Ÿ”ด', + 'high': '๐ŸŸ ', + 'medium': '๐ŸŸก', + 'low': 'โšช' +} + +# 32px logo for PR comment headers (PNG file stored in assets/) +# TODO: Switch back to main branch URL once assets/socket-logo.png before merging. +# Using feature branch URL for now so the logo renders during PR testing. +# SOCKET_LOGO_URL = 'https://raw.githubusercontent.com/SocketDev/socket-basics/main/assets/socket-logo.png' +SOCKET_LOGO_URL = 'https://raw.githubusercontent.com/SocketDev/socket-basics/lelia/pr-comment-enhancements/assets/socket-logo.png' +SOCKET_LOGO_IMG = f'' + + +def format_title_with_logo(title: str) -> str: + """Format an H2 title with the Socket logo inline. + + Args: + title: The title text (e.g., "Socket SAST JavaScript") + + Returns: + Markdown H2 header with inline logo image + """ + return f"## {SOCKET_LOGO_IMG} {title}" + + +def wrap_pr_comment_section( + section_id: str, + title: str, + body: str, + full_scan_url: Optional[str] = None +) -> str: + """Wrap scanner output in the standard PR comment structure. + + Every scanner section gets the same wrapper: HTML comment markers for + idempotent updates, a branded H2 title with logo, an optional scan + report link, and the scanner-specific body content. + + Args: + section_id: Unique identifier for this section (e.g., "socket-tier1", + "trivy-container", "sast-javascript"). Used in HTML comment + markers so the notifier can find and update existing sections. + title: Display title (e.g., "Socket SAST JavaScript") + body: Scanner-specific markdown content (summary tables, findings, etc.) + full_scan_url: Optional URL to full scan report + + Returns: + Complete markdown section ready to post as a PR comment + """ + title_header = format_title_with_logo(title) + scan_link = format_scan_link_section(full_scan_url) + + return f""" +{title_header} + +{scan_link} +{body} +""" + + +# ============================================================================ +# Configuration Helper +# ============================================================================ + +def get_feature_flags(config) -> Dict[str, Any]: + """Extract PR comment feature flags from config object. + + Args: + config: Configuration object (may be None) + + Returns: + Dictionary of feature flags with defaults + """ + if not config: + return { + 'enable_links': True, + 'enable_collapse': True, + 'collapse_non_critical': True, + 'enable_code_fencing': True, + 'show_rule_names': True, + 'repository': '', + 'commit_hash': '', + 'full_scan_url': None + } + + return { + 'enable_links': config.get('pr_comment_links_enabled', True), + 'enable_collapse': config.get('pr_comment_collapse_enabled', True), + 'collapse_non_critical': config.get('pr_comment_collapse_non_critical', True), + 'enable_code_fencing': config.get('pr_comment_code_fencing_enabled', True), + 'show_rule_names': config.get('pr_comment_show_rule_names', True), + 'repository': config.repo if hasattr(config, 'repo') else '', + 'commit_hash': config.commit_hash if hasattr(config, 'commit_hash') else '', + 'full_scan_url': config.get('full_scan_html_url') if config else None + } + + +# ============================================================================ +# Language Detection +# ============================================================================ + +def detect_language_from_filename(filename: str) -> str: + """Detect programming language from file extension. + + Args: + filename: File path or name + + Returns: + Markdown language identifier for code fencing + """ + ext_map = { + '.js': 'javascript', + '.jsx': 'javascript', + '.ts': 'typescript', + '.tsx': 'typescript', + '.py': 'python', + '.go': 'go', + '.java': 'java', + '.kt': 'kotlin', + '.scala': 'scala', + '.rb': 'ruby', + '.php': 'php', + '.cs': 'csharp', + '.cpp': 'cpp', + '.c': 'c', + '.h': 'c', + '.hpp': 'cpp', + '.swift': 'swift', + '.rs': 'rust', + '.ex': 'elixir', + '.exs': 'elixir', + '.erl': 'erlang', + '.sh': 'bash', + '.yaml': 'yaml', + '.yml': 'yaml', + '.json': 'json', + '.xml': 'xml', + '.html': 'html', + '.css': 'css', + '.sql': 'sql', + '.dockerfile': 'dockerfile', + '.Dockerfile': 'dockerfile', + } + + ext = Path(filename).suffix.lower() + + # Special case for Dockerfile (no extension) + if Path(filename).name.lower() in ['dockerfile', 'dockerfile.dev', 'dockerfile.prod']: + return 'dockerfile' + + return ext_map.get(ext, '') + + +# ============================================================================ +# GitHub URL Building +# ============================================================================ + +def build_github_file_url( + repository: str, + commit_hash: str, + filepath: str, + line_start: Optional[int] = None, + line_end: Optional[int] = None +) -> str: + """Build a GitHub URL to a specific file and line range. + + Args: + repository: GitHub repository (e.g., "owner/repo") + commit_hash: Git commit hash + filepath: Relative file path from repository root + line_start: Starting line number (optional) + line_end: Ending line number (optional) + + Returns: + GitHub URL string (empty if repository/commit missing) + """ + if not repository or not commit_hash: + return '' + + # Clean filepath - remove GitHub Actions workspace prefixes + clean_path = filepath + + # Strip common GitHub Actions workspace prefixes + workspace_prefixes = [ + '/github/workspace/', + 'github/workspace/', + '/home/runner/work/', + ] + + for prefix in workspace_prefixes: + if clean_path.startswith(prefix): + clean_path = clean_path[len(prefix):] + break + + # For /home/runner/work/{owner}/{repo}/ pattern, strip the repo path + # Pattern: /home/runner/work/owner/repo/path/to/file.js + if clean_path.startswith('/home/runner/work/') or clean_path.startswith('home/runner/work/'): + parts = clean_path.split('/') + # Find where the actual file path starts (after owner/repo) + if len(parts) > 4: # ['', 'home', 'runner', 'work', 'owner', 'repo', ...] + clean_path = '/'.join(parts[6:]) if parts[0] == '' else '/'.join(parts[5:]) + + # Remove leading ./ or / + clean_path = clean_path.lstrip('./') + + # Base URL + url = f"https://github.com/{repository}/blob/{commit_hash}/{clean_path}" + + # Add line anchor + if line_start is not None: + if line_end is not None and line_end != line_start: + url += f"#L{line_start}-L{line_end}" + else: + url += f"#L{line_start}" + + return url + + +def format_file_location_link( + filepath: str, + line_start: Optional[int] = None, + line_end: Optional[int] = None, + repository: str = '', + commit_hash: str = '', + enable_links: bool = True +) -> str: + """Format a file location as plain text or clickable markdown link. + + Uses common file:line convention (e.g., "file.js:72" or "file.js:72-85") + + Args: + filepath: File path + line_start: Starting line number + line_end: Ending line number + repository: GitHub repository + commit_hash: Git commit hash + enable_links: Whether to create clickable links + + Returns: + Formatted string (plain or markdown link) + """ + # Build display text using file:line convention + if line_start is not None: + if line_end is not None and line_end != line_start: + location_text = f"`{filepath}:{line_start}-{line_end}`" + else: + location_text = f"`{filepath}:{line_start}`" + else: + location_text = f"`{filepath}`" + + # Add link if enabled and metadata available + if enable_links and repository and commit_hash: + url = build_github_file_url(repository, commit_hash, filepath, line_start, line_end) + if url: + # Make the entire location clickable (no backticks inside link text) + if line_start is not None: + if line_end is not None and line_end != line_start: + return f"[{filepath}:{line_start}-{line_end}]({url})" + else: + return f"[{filepath}:{line_start}]({url})" + else: + return f"[{filepath}]({url})" + + return location_text + + +# ============================================================================ +# Trace Formatting (for Socket Tier 1 reachability) +# ============================================================================ + +def format_trace_with_links( + trace_lines: List[str], + repository: str, + commit_hash: str, + enable_links: bool = True +) -> str: + """Format trace lines with clickable GitHub links. + + Args: + trace_lines: List of trace strings (format: "package - filename.ext 10:5-15:20") + repository: GitHub repository + commit_hash: Git commit hash + enable_links: Whether to create clickable links + + Returns: + Formatted trace string with optional links + """ + if not trace_lines: + return '' + + formatted_lines = [] + + for line in trace_lines: + if not enable_links: + formatted_lines.append(line) + continue + + # Parse trace format: "package_name - filename.js 72:12-75:6" + # or " -> module_name path/to/file.py 45:2" + # Note: package names can contain dashes, so we look for " - " (space-dash-space) as separator + + # Try format with " - " separator first + match = re.match( + r'^(\s*)(.+?)\s+-\s+([^\s]+)\s+(\d+):(\d+)(?:-(\d+):(\d+))?$', + line + ) + + if not match: + # Try format with "-> " prefix (no " - " separator) + match = re.match( + r'^(\s+->)\s+(.+?)\s+([^\s]+)\s+(\d+):(\d+)(?:-(\d+):(\d+))?$', + line + ) + + if match: + prefix = match.group(1) + package = match.group(2).strip() + filename = match.group(3) + line_start = int(match.group(4)) + col_start = match.group(5) + line_end = int(match.group(6)) if match.group(6) else line_start + col_end = match.group(7) + + # Build GitHub URL + github_url = build_github_file_url( + repository, commit_hash, filename, line_start, line_end + ) + + if github_url: + # Create markdown link + location = f"{line_start}:{col_start}" + if line_end != line_start or (col_end and col_end != col_start): + location += f"-{line_end}:{col_end}" + + # Format based on whether it has the " - " separator or "-> " prefix + if ' - ' in line or line.strip().startswith('->'): + # Preserve original structure + if '-> ' in prefix: + formatted = f"{prefix} {package} [{filename} {location}]({github_url})" + else: + formatted = f"{prefix}{package} - [{filename} {location}]({github_url})" + else: + formatted = f"{prefix}{package} - [{filename} {location}]({github_url})" + + formatted_lines.append(formatted) + else: + formatted_lines.append(line) + else: + # Couldn't parse, keep original + formatted_lines.append(line) + + return '\n'.join(formatted_lines) + + +# ============================================================================ +# Collapsible Sections +# ============================================================================ + +def build_severity_summary(severity_counts: Dict[str, int]) -> str: + """Build a severity summary string with emojis. + + Args: + severity_counts: Dictionary mapping severity levels to counts + + Returns: + Formatted severity summary (e.g., "๐Ÿ”ด Critical: 3 | ๐ŸŸ  High: 14") + """ + parts = [] + for severity in ['critical', 'high', 'medium', 'low']: + count = severity_counts.get(severity, 0) + if count > 0: + emoji = SEVERITY_EMOJI.get(severity, 'โšช') + parts.append(f"{emoji} {severity.capitalize()}: {count}") + + return " | ".join(parts) if parts else "No issues" + + +def create_collapsible_section( + title: str, + content: str, + severity_counts: Optional[Dict[str, int]] = None, + auto_expand: bool = False +) -> str: + """Create a collapsible
section with optional severity summary. + + Args: + title: Section title (will be bolded) + content: Section content (markdown) + severity_counts: Optional severity counts to show in summary + auto_expand: Whether to auto-expand the section + + Returns: + Markdown collapsible section + """ + summary_line = f"{title}" + + if severity_counts: + severity_summary = build_severity_summary(severity_counts) + summary_line += f" ({severity_summary})" + + open_attr = ' open' if auto_expand else '' + + return f""" +{summary_line} + +{content} + +
+ +""" + + +# ============================================================================ +# Code Fencing +# ============================================================================ + +def format_code_block( + code: str, + filepath: Optional[str] = None, + language: Optional[str] = None, + enable_fencing: bool = True +) -> str: + """Format code in a language-aware fenced code block. + + Args: + code: Code content + filepath: Optional file path to detect language from + language: Explicit language override + enable_fencing: Whether to add code fencing + + Returns: + Formatted code block + """ + if not code or not code.strip(): + return '' + + if not enable_fencing: + return code + + # Determine language + lang = language or '' + if not lang and filepath: + lang = detect_language_from_filename(filepath) + + return f"```{lang}\n{code}\n```" + + +# ============================================================================ +# Rule Name Extraction +# ============================================================================ + +def extract_rule_name(alert_data: Dict[str, Any]) -> str: + """Extract rule name from alert data. + + Args: + alert_data: Alert dictionary + + Returns: + Rule name or empty string + """ + # Check various possible sources + props = alert_data.get('props', {}) or {} + + # Priority order for rule names + rule_name = ( + props.get('rule') or + props.get('rule_name') or + props.get('ruleName') or + props.get('ruleId') or + alert_data.get('rule') or + alert_data.get('type') or + alert_data.get('title') or + '' + ) + + return str(rule_name) if rule_name else '' + + +# ============================================================================ +# Scan Link Formatting +# ============================================================================ + +def format_scan_link_section(full_scan_url: Optional[str]) -> str: + """Format the full scan report link section. + + Args: + full_scan_url: URL to full scan report + + Returns: + Formatted markdown section (empty if no URL) + """ + if not full_scan_url: + return '' + + return f"\n\n๐Ÿ”— **[View Full Socket Scan Report]({full_scan_url})**\n\n---\n" + + +# ============================================================================ +# CVE/Vulnerability Formatting +# ============================================================================ + +def format_cve_link(cve_id: str) -> str: + """Format a CVE ID as a clickable link to NVD. + + Args: + cve_id: CVE identifier (e.g., "CVE-2021-23337") + + Returns: + Markdown link to NVD, or plain text if not a valid CVE ID + """ + if not cve_id: + return '' + + # Check if it's a CVE ID (format: CVE-YYYY-NNNNN) + if cve_id.upper().startswith('CVE-'): + return f"[{cve_id}](https://nvd.nist.gov/vuln/detail/{cve_id})" + + # Not a CVE, return as-is + return cve_id + + +def format_vulnerability_header( + vuln_id: str, + severity: str, + cvss_score: Optional[float] = None, + emoji: Optional[str] = None +) -> str: + """Format a vulnerability header with severity and optional CVSS score. + + Args: + vuln_id: Vulnerability ID (CVE, GHSA, etc.) + severity: Severity level (critical, high, medium, low) + cvss_score: Optional CVSS score (0.0-10.0) + emoji: Optional severity emoji (will use SEVERITY_EMOJI if not provided) + + Returns: + Formatted header string + """ + # Get emoji if not provided + if not emoji: + emoji = SEVERITY_EMOJI.get(severity.lower(), 'โšช') + + # Format CVE as link if applicable + vuln_display = format_cve_link(vuln_id) if vuln_id else 'Unknown' + + # Build header + header = f"{emoji} **{vuln_display}** โ€ข {severity.upper()}" + + # Add CVSS score if available + if cvss_score is not None: + header += f" (CVSS {cvss_score})" + + return header diff --git a/socket_basics/core/notification/github_pr_notifier.py b/socket_basics/core/notification/github_pr_notifier.py index 524b4e9..555d03e 100644 --- a/socket_basics/core/notification/github_pr_notifier.py +++ b/socket_basics/core/notification/github_pr_notifier.py @@ -51,14 +51,11 @@ def notify(self, facts: Dict[str, Any]) -> None: valid_notifications = [] for item in notifications: if isinstance(item, dict) and 'title' in item and 'content' in item: - # Append full scan URL to content if available - content = item['content'] - if self.full_scan_url: - content += f"\n\n---\n\n๐Ÿ”— [View Full Socket Scan]({self.full_scan_url})\n" - valid_notifications.append({'title': item['title'], 'content': content}) + # Full scan URL is now handled in the formatter itself + valid_notifications.append({'title': item['title'], 'content': item['content']}) else: logger.warning('GithubPRNotifier: skipping invalid notification item: %s', type(item)) - + if not valid_notifications: return @@ -118,6 +115,12 @@ def notify(self, facts: Dict[str, Any]) -> None: else: logger.error('GithubPRNotifier: failed to post individual comment') + # Add labels to PR if enabled + if self.config.get('pr_labels_enabled', True) and pr_number: + labels = self._determine_pr_labels(valid_notifications) + if labels: + self._add_pr_labels(pr_number, labels) + def _send_pr_comment(self, facts: Dict[str, Any], title: str, content: str) -> None: """Send a single PR comment with title and content.""" if not self.token: @@ -372,4 +375,166 @@ def _post_comment(self, pr_number: int, comment_body: str) -> bool: return False except Exception as e: logger.error('GithubPRNotifier: exception posting comment: %s', e) - return False \ No newline at end of file + return False + + def _ensure_label_exists_with_color(self, label_name: str, color: str, description: str = '') -> bool: + """Ensure a label exists in the repository with the specified color. + + If the label doesn't exist, it will be created with the given color. + If it already exists, we leave it alone (don't update existing labels). + + Args: + label_name: Name of the label + color: Hex color code (without #), e.g., 'D73A4A' + description: Optional description for the label + + Returns: + True if label exists/was created, False otherwise + """ + if not self.repository: + return False + + try: + import requests + headers = { + 'Authorization': f'token {self.token}', + 'Accept': 'application/vnd.github.v3+json' + } + + # Check if label exists + check_url = f"{self.api_base}/repos/{self.repository}/labels/{label_name}" + resp = requests.get(check_url, headers=headers, timeout=10) + + if resp.status_code == 200: + # Label already exists, don't modify it + logger.debug('GithubPRNotifier: label "%s" already exists', label_name) + return True + elif resp.status_code == 404: + # Label doesn't exist, create it + create_url = f"{self.api_base}/repos/{self.repository}/labels" + payload = { + 'name': label_name, + 'color': color, + 'description': description + } + + create_resp = requests.post(create_url, headers=headers, json=payload, timeout=10) + if create_resp.status_code == 201: + logger.info('GithubPRNotifier: created label "%s" with color #%s', label_name, color) + return True + else: + logger.warning('GithubPRNotifier: failed to create label "%s": %s', + label_name, create_resp.status_code) + return False + else: + logger.warning('GithubPRNotifier: unexpected response checking label: %s', resp.status_code) + return False + + except Exception as e: + logger.debug('GithubPRNotifier: exception ensuring label exists: %s', e) + return False + + def _add_pr_labels(self, pr_number: int, labels: List[str]) -> bool: + """Add labels to a PR, ensuring they exist with appropriate colors. + + Args: + pr_number: PR number + labels: List of label names to add + + Returns: + True if successful, False otherwise + """ + if not self.repository or not labels: + return False + + # Color mapping for severity labels (matching emoji colors) + label_colors = { + 'security: critical': ('D73A4A', 'Critical security vulnerabilities'), + 'security: high': ('D93F0B', 'High severity security issues'), + 'security: medium': ('FBCA04', 'Medium severity security issues'), + 'security: low': ('E4E4E4', 'Low severity security issues'), + } + + # Ensure labels exist with correct colors + for label in labels: + # Get color and description if this is a known severity label + color_info = label_colors.get(label) + if color_info: + color, description = color_info + self._ensure_label_exists_with_color(label, color, description) + # For custom label names, use a default color + elif ':' in label: + # Try to infer severity from label name + label_lower = label.lower() + if 'critical' in label_lower: + self._ensure_label_exists_with_color(label, 'D73A4A', 'Critical security vulnerabilities') + elif 'high' in label_lower: + self._ensure_label_exists_with_color(label, 'D93F0B', 'High severity security issues') + elif 'medium' in label_lower: + self._ensure_label_exists_with_color(label, 'FBCA04', 'Medium severity security issues') + elif 'low' in label_lower: + self._ensure_label_exists_with_color(label, 'E4E4E4', 'Low severity security issues') + + try: + import requests + headers = { + 'Authorization': f'token {self.token}', + 'Accept': 'application/vnd.github.v3+json' + } + + url = f"{self.api_base}/repos/{self.repository}/issues/{pr_number}/labels" + payload = {'labels': labels} + + resp = requests.post(url, headers=headers, json=payload, timeout=10) + if resp.status_code == 200: + logger.info('GithubPRNotifier: added labels to PR %s: %s', pr_number, ', '.join(labels)) + return True + else: + logger.warning('GithubPRNotifier: failed to add labels: %s', resp.status_code) + return False + except Exception as e: + logger.error('GithubPRNotifier: exception adding labels: %s', e) + return False + + def _determine_pr_labels(self, notifications: List[Dict[str, Any]]) -> List[str]: + """Determine which labels to add based on notifications. + + Args: + notifications: List of notification dictionaries + + Returns: + List of label names to add + """ + severities_found = set() + + # Scan notifications for severity indicators + for notif in notifications: + content = notif.get('content', '') + + # Look for severity indicators in content + # Pattern: "Critical: X" where X > 0 + import re + critical_match = re.search(r'Critical:\s*(\d+)', content) + high_match = re.search(r'High:\s*(\d+)', content) + medium_match = re.search(r'Medium:\s*(\d+)', content) + + if critical_match and int(critical_match.group(1)) > 0: + severities_found.add('critical') + if high_match and int(high_match.group(1)) > 0: + severities_found.add('high') + if medium_match and int(medium_match.group(1)) > 0: + severities_found.add('medium') + + # Map severities to label names (using configurable labels) + labels = [] + if 'critical' in severities_found: + label_name = self.config.get('pr_label_critical', 'security: critical') + labels.append(label_name) + elif 'high' in severities_found: + label_name = self.config.get('pr_label_high', 'security: high') + labels.append(label_name) + elif 'medium' in severities_found: + label_name = self.config.get('pr_label_medium', 'security: medium') + labels.append(label_name) + + return labels \ No newline at end of file diff --git a/socket_basics/notifications.yaml b/socket_basics/notifications.yaml index 066873e..2de7467 100644 --- a/socket_basics/notifications.yaml +++ b/socket_basics/notifications.yaml @@ -90,6 +90,60 @@ notifiers: option: --github-api-url env_variable: GITHUB_API_URL type: str + - name: pr_comment_links_enabled + option: --pr-comment-links + env_variable: INPUT_PR_COMMENT_LINKS_ENABLED + type: bool + default: true + description: "Enable clickable file/line links in PR comments" + - name: pr_comment_collapse_enabled + option: --pr-comment-collapse + env_variable: INPUT_PR_COMMENT_COLLAPSE_ENABLED + type: bool + default: true + description: "Enable collapsible sections in PR comments" + - name: pr_comment_collapse_non_critical + option: --pr-comment-collapse-non-critical + env_variable: INPUT_PR_COMMENT_COLLAPSE_NON_CRITICAL + type: bool + default: true + description: "Auto-collapse non-critical findings (critical stays expanded)" + - name: pr_comment_code_fencing_enabled + option: --pr-comment-code-fencing + env_variable: INPUT_PR_COMMENT_CODE_FENCING_ENABLED + type: bool + default: true + description: "Enable language-aware code fencing for trace output" + - name: pr_comment_show_rule_names + option: --pr-comment-show-rules + env_variable: INPUT_PR_COMMENT_SHOW_RULE_NAMES + type: bool + default: true + description: "Show explicit rule names for each finding" + - name: pr_labels_enabled + option: --pr-labels + env_variable: INPUT_PR_LABELS_ENABLED + type: bool + default: true + description: "Add severity-based labels to PRs" + - name: pr_label_critical + option: --pr-label-critical + env_variable: INPUT_PR_LABEL_CRITICAL + type: str + default: "security: critical" + description: "Label name for critical severity findings" + - name: pr_label_high + option: --pr-label-high + env_variable: INPUT_PR_LABEL_HIGH + type: str + default: "security: high" + description: "Label name for high severity findings" + - name: pr_label_medium + option: --pr-label-medium + env_variable: INPUT_PR_LABEL_MEDIUM + type: str + default: "security: medium" + description: "Label name for medium severity findings" msteams: module_path: "socket_basics.core.notification.ms_teams_notifier" diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..bfb7519 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,258 @@ +# Unit Tests + +This directory contains **unit and integration tests** for Socket Basics using pytest. + +## Quick Start + +```bash +# Setup (first time only) +python3 -m venv venv +source venv/bin/activate +pip install -e ".[dev]" + +# Run all tests +pytest + +# Run with coverage +pytest --cov=socket_basics +``` + +## Test Structure + +``` +tests/ +โ”œโ”€โ”€ test_github_helpers.py # Helper functions for GitHub PR comments +โ””โ”€โ”€ ... # Other unit tests +``` + +## Writing Tests + +### Test File Naming +- Test files: `test_*.py` +- Test functions: `test_*` +- Test classes: `Test*` + +### Example Test + +```python +import pytest +from socket_basics.module import function_to_test + +def test_basic_functionality(): + """Test basic functionality with valid input.""" + result = function_to_test("input") + assert result == "expected_output" + +def test_edge_case(): + """Test edge case handling.""" + result = function_to_test("") + assert result == "" + +def test_error_handling(): + """Test error handling.""" + with pytest.raises(ValueError): + function_to_test(None) +``` + +### Test Classes + +Use test classes to group related tests: + +```python +class TestGitHubHelpers: + """Tests for GitHub helper functions.""" + + def test_url_building(self): + url = build_github_file_url("owner/repo", "abc123", "file.py", 10) + assert "github.com" in url + + def test_language_detection(self): + lang = detect_language_from_filename("app.js") + assert lang == "javascript" +``` + +## Running Tests + +### Run All Tests +```bash +pytest +``` + +### Run Specific Test File +```bash +pytest tests/test_github_helpers.py +``` + +### Run Specific Test Function +```bash +pytest tests/test_github_helpers.py::test_detect_language_from_filename +``` + +### Run Specific Test Class +```bash +pytest tests/test_github_helpers.py::TestDetectLanguageFromFilename +``` + +### Run with Verbose Output +```bash +pytest -v +``` + +### Run with Coverage Report +```bash +# Terminal coverage report +pytest --cov=socket_basics tests/ + +# HTML coverage report +pytest --cov=socket_basics --cov-report=html tests/ +open htmlcov/index.html +``` + +### Run with Markers (Future Enhancement) +```bash +# Run only fast tests +pytest -m "not slow" + +# Run only integration tests +pytest -m integration +``` + +## Test Categories + +### Unit Tests +**Purpose:** Test individual functions in isolation +**Speed:** Fast (milliseconds) +**Dependencies:** None (use mocks) +**Location:** `tests/test_*.py` + +**Example:** +```python +def test_detect_language_from_filename(): + assert detect_language_from_filename('app.js') == 'javascript' + assert detect_language_from_filename('main.py') == 'python' +``` + +### Integration Tests (Future) +**Purpose:** Test multiple components together +**Speed:** Moderate (seconds) +**Dependencies:** Real configs, but mocked external APIs +**Location:** `tests/integration/test_*.py` + +**Example:** +```python +def test_formatter_with_config(): + config = create_test_config() + result = format_notifications(components, config) + assert len(result) > 0 +``` + +## Mocking External Dependencies + +Use pytest fixtures for common test data: + +```python +@pytest.fixture +def mock_config(): + """Provide a mock configuration object.""" + return { + 'repo': 'owner/repo', + 'commit_hash': 'abc123', + 'pr_comment_links_enabled': True + } + +def test_with_config(mock_config): + result = format_with_config(mock_config) + assert result is not None +``` + +## Testing Best Practices + +### โœ… DO: +- Keep tests fast and isolated +- Test one thing per test function +- Use descriptive test names +- Test both success and error cases +- Use fixtures for common test data +- Run tests before committing + +### โŒ DON'T: +- Make external API calls (mock them) +- Depend on test execution order +- Use hardcoded paths (use fixtures) +- Test implementation details +- Skip error case testing + +## Continuous Integration + +Tests run automatically on: +- Every pull request +- Every commit to main branch +- Before releases + +**GitHub Actions Example:** +```yaml +- name: Run Tests + run: | + pip install -e ".[dev]" + pytest --cov=socket_basics tests/ +``` + +## Debugging Tests + +### Run Single Test with Debug Output +```bash +pytest tests/test_github_helpers.py::test_name -vv -s +``` + +### Drop into debugger on failure +```bash +pytest --pdb +``` + +### Print statements in tests +```python +def test_example(): + result = function() + print(f"Debug: result = {result}") # Use -s flag to see output + assert result == expected +``` + +## Coverage Goals + +- **Target:** 80%+ code coverage for new features +- **Critical paths:** 100% coverage (authentication, security logic) +- **Helper functions:** 90%+ coverage + +Check coverage: +```bash +pytest --cov=socket_basics --cov-report=term-missing +``` + +## Related Testing + +For **end-to-end testing** with real vulnerable applications, see: +- `../app_tests/` - Deliberately vulnerable apps for scanner validation +- `../app_tests/README.md` - E2E testing documentation (if it exists) + +## Test Data + +Test fixtures and sample data should be: +- **Small** - Minimal data needed for the test +- **Realistic** - Representative of actual usage +- **Self-contained** - No external dependencies + +**Example:** +```python +SAMPLE_TRACE = """owasp-goat - server.js 72:12-75:6 + -> express routes/auth.js 45:2""" + +def test_trace_parsing(): + result = parse_trace(SAMPLE_TRACE) + assert len(result) == 2 +``` + +## Questions? + +- See main [README.md](../README.md) for project overview +- See [docs/](../docs/) for detailed documentation +- Check existing tests for examples diff --git a/tests/test_github_helpers.py b/tests/test_github_helpers.py new file mode 100644 index 0000000..227a1b7 --- /dev/null +++ b/tests/test_github_helpers.py @@ -0,0 +1,428 @@ +"""Unit tests for GitHub PR comment helper functions.""" + +import pytest +from socket_basics.core.notification import github_pr_helpers as github_helpers + + +class TestDetectLanguageFromFilename: + """Tests for detect_language_from_filename function.""" + + def test_javascript_extensions(self): + assert github_helpers.detect_language_from_filename('app.js') == 'javascript' + assert github_helpers.detect_language_from_filename('component.jsx') == 'javascript' + + def test_typescript_extensions(self): + assert github_helpers.detect_language_from_filename('main.ts') == 'typescript' + assert github_helpers.detect_language_from_filename('component.tsx') == 'typescript' + + def test_python_extensions(self): + assert github_helpers.detect_language_from_filename('main.py') == 'python' + assert github_helpers.detect_language_from_filename('script.py') == 'python' + + def test_go_extensions(self): + assert github_helpers.detect_language_from_filename('test.go') == 'go' + + def test_java_extensions(self): + assert github_helpers.detect_language_from_filename('Main.java') == 'java' + + def test_rust_extensions(self): + assert github_helpers.detect_language_from_filename('main.rs') == 'rust' + + def test_ruby_extensions(self): + assert github_helpers.detect_language_from_filename('app.rb') == 'ruby' + + def test_php_extensions(self): + assert github_helpers.detect_language_from_filename('index.php') == 'php' + + def test_unknown_extensions(self): + assert github_helpers.detect_language_from_filename('unknown.xyz') == '' + assert github_helpers.detect_language_from_filename('noextension') == '' + + def test_with_paths(self): + assert github_helpers.detect_language_from_filename('src/components/app.js') == 'javascript' + assert github_helpers.detect_language_from_filename('/absolute/path/main.py') == 'python' + + def test_case_insensitive(self): + assert github_helpers.detect_language_from_filename('App.JS') == 'javascript' + assert github_helpers.detect_language_from_filename('Main.PY') == 'python' + + +class TestBuildGithubFileUrl: + """Tests for build_github_file_url function.""" + + def test_basic_url_without_lines(self): + url = github_helpers.build_github_file_url( + 'owner/repo', + 'abc123', + 'src/main.py' + ) + assert url == 'https://github.com/owner/repo/blob/abc123/src/main.py' + + def test_url_with_single_line(self): + url = github_helpers.build_github_file_url( + 'owner/repo', + 'abc123', + 'src/main.py', + 10 + ) + assert url == 'https://github.com/owner/repo/blob/abc123/src/main.py#L10' + + def test_url_with_line_range(self): + url = github_helpers.build_github_file_url( + 'owner/repo', + 'abc123', + 'src/main.py', + 10, + 15 + ) + assert url == 'https://github.com/owner/repo/blob/abc123/src/main.py#L10-L15' + + def test_url_with_same_start_end_line(self): + url = github_helpers.build_github_file_url( + 'owner/repo', + 'abc123', + 'src/main.py', + 10, + 10 + ) + assert url == 'https://github.com/owner/repo/blob/abc123/src/main.py#L10' + + def test_cleans_leading_dot_slash(self): + url = github_helpers.build_github_file_url( + 'owner/repo', + 'abc123', + './src/main.py' + ) + assert url == 'https://github.com/owner/repo/blob/abc123/src/main.py' + + def test_cleans_leading_slash(self): + url = github_helpers.build_github_file_url( + 'owner/repo', + 'abc123', + '/src/main.py' + ) + # Note: lstrip('./') removes leading ./ but not just / + # Need to handle both cases + assert 'src/main.py' in url + + def test_empty_repository(self): + url = github_helpers.build_github_file_url( + '', + 'abc123', + 'src/main.py' + ) + assert url == '' + + def test_empty_commit_hash(self): + url = github_helpers.build_github_file_url( + 'owner/repo', + '', + 'src/main.py' + ) + assert url == '' + + def test_none_repository(self): + url = github_helpers.build_github_file_url( + None, + 'abc123', + 'src/main.py' + ) + assert url == '' + + def test_none_commit_hash(self): + url = github_helpers.build_github_file_url( + 'owner/repo', + None, + 'src/main.py' + ) + assert url == '' + + +class TestFormatTraceWithLinks: + """Tests for format_trace_with_links function.""" + + def test_empty_trace_lines(self): + result = github_helpers.format_trace_with_links( + [], + 'owner/repo', + 'abc123' + ) + assert result == '' + + def test_basic_trace_format(self): + trace_lines = [ + 'owasp-goat - server.js 72:12-75:6' + ] + + result = github_helpers.format_trace_with_links( + trace_lines, + 'owner/repo', + 'abc123', + enable_links=True + ) + + assert 'https://github.com/owner/repo' in result + assert '[server.js 72:12-75:6]' in result + assert 'owasp-goat' in result + + def test_indented_trace_format(self): + trace_lines = [ + ' -> express routes/auth.js 45:2' + ] + + result = github_helpers.format_trace_with_links( + trace_lines, + 'owner/repo', + 'abc123', + enable_links=True + ) + + assert 'https://github.com/owner/repo' in result + assert '[routes/auth.js 45:2]' in result + assert 'express' in result + assert ' ->' in result # Preserves indentation + + def test_multiple_trace_lines(self): + trace_lines = [ + 'owasp-goat - server.js 72:12-75:6', + ' -> express routes/auth.js 45:2' + ] + + result = github_helpers.format_trace_with_links( + trace_lines, + 'owner/repo', + 'abc123', + enable_links=True + ) + + lines = result.split('\n') + assert len(lines) == 2 + assert '[server.js 72:12-75:6]' in lines[0] + assert '[routes/auth.js 45:2]' in lines[1] + + def test_links_disabled(self): + trace_lines = [ + 'owasp-goat - server.js 72:12-75:6' + ] + + result = github_helpers.format_trace_with_links( + trace_lines, + 'owner/repo', + 'abc123', + enable_links=False + ) + + assert 'https://github.com' not in result + assert result == 'owasp-goat - server.js 72:12-75:6' + + def test_unparseable_line_preserved(self): + trace_lines = [ + 'some random text without proper format' + ] + + result = github_helpers.format_trace_with_links( + trace_lines, + 'owner/repo', + 'abc123', + enable_links=True + ) + + assert result == 'some random text without proper format' + + def test_single_line_number(self): + trace_lines = [ + 'package - file.js 42:5' + ] + + result = github_helpers.format_trace_with_links( + trace_lines, + 'owner/repo', + 'abc123', + enable_links=True + ) + + assert '[file.js 42:5]' in result + assert '#L42' in result + + def test_missing_repository(self): + trace_lines = [ + 'package - file.js 42:5' + ] + + result = github_helpers.format_trace_with_links( + trace_lines, + '', + 'abc123', + enable_links=True + ) + + # Should preserve original format if URL can't be built + assert result == 'package - file.js 42:5' + + +class TestExtractRuleName: + """Tests for extract_rule_name function.""" + + def test_from_props_rule(self): + alert = { + 'props': { + 'rule': 'CVE-2024-1234' + } + } + assert github_helpers.extract_rule_name(alert) == 'CVE-2024-1234' + + def test_from_props_rule_name(self): + alert = { + 'props': { + 'rule_name': 'GHSA-xxxx-yyyy-zzzz' + } + } + assert github_helpers.extract_rule_name(alert) == 'GHSA-xxxx-yyyy-zzzz' + + def test_from_props_ruleName_camelCase(self): + alert = { + 'props': { + 'ruleName': 'custom-rule' + } + } + assert github_helpers.extract_rule_name(alert) == 'custom-rule' + + def test_from_alert_rule(self): + alert = { + 'rule': 'some-rule' + } + assert github_helpers.extract_rule_name(alert) == 'some-rule' + + def test_from_alert_type(self): + alert = { + 'type': 'vulnerability' + } + assert github_helpers.extract_rule_name(alert) == 'vulnerability' + + def test_priority_order(self): + # props.rule should take priority over props.rule_name + alert = { + 'props': { + 'rule': 'high-priority', + 'rule_name': 'low-priority' + } + } + assert github_helpers.extract_rule_name(alert) == 'high-priority' + + def test_empty_alert(self): + alert = {} + assert github_helpers.extract_rule_name(alert) == '' + + def test_none_props(self): + alert = { + 'props': None + } + assert github_helpers.extract_rule_name(alert) == '' + + def test_empty_values(self): + alert = { + 'props': { + 'rule': '', + 'rule_name': '' + } + } + assert github_helpers.extract_rule_name(alert) == '' + + +class TestFormatCveLink: + """Tests for format_cve_link function.""" + + def test_valid_cve_id(self): + result = github_helpers.format_cve_link('CVE-2021-23337') + assert result == '[CVE-2021-23337](https://nvd.nist.gov/vuln/detail/CVE-2021-23337)' + + def test_cve_id_lowercase(self): + result = github_helpers.format_cve_link('cve-2021-44228') + assert result == '[cve-2021-44228](https://nvd.nist.gov/vuln/detail/cve-2021-44228)' + + def test_non_cve_id(self): + result = github_helpers.format_cve_link('GHSA-abcd-1234-efgh') + assert result == 'GHSA-abcd-1234-efgh' + + def test_empty_string(self): + result = github_helpers.format_cve_link('') + assert result == '' + + def test_none_value(self): + result = github_helpers.format_cve_link(None) + assert result == '' + + +class TestFormatVulnerabilityHeader: + """Tests for format_vulnerability_header function.""" + + def test_with_cvss_score(self): + result = github_helpers.format_vulnerability_header( + 'CVE-2021-23337', + 'critical', + cvss_score=9.8 + ) + assert '๐Ÿ”ด' in result + assert '[CVE-2021-23337](https://nvd.nist.gov/vuln/detail/CVE-2021-23337)' in result + assert 'CRITICAL' in result + assert 'CVSS 9.8' in result + + def test_without_cvss_score(self): + result = github_helpers.format_vulnerability_header( + 'CVE-2021-23338', + 'high' + ) + assert '๐ŸŸ ' in result + assert '[CVE-2021-23338](https://nvd.nist.gov/vuln/detail/CVE-2021-23338)' in result + assert 'HIGH' in result + assert 'CVSS' not in result + + def test_with_custom_emoji(self): + result = github_helpers.format_vulnerability_header( + 'CVE-2021-23337', + 'critical', + cvss_score=9.8, + emoji='โš ๏ธ' + ) + assert 'โš ๏ธ' in result + assert '๐Ÿ”ด' not in result + + def test_non_cve_vulnerability(self): + result = github_helpers.format_vulnerability_header( + 'GHSA-abcd-1234-efgh', + 'high', + cvss_score=7.5 + ) + assert '๐ŸŸ ' in result + assert 'GHSA-abcd-1234-efgh' in result + assert 'https://nvd.nist.gov' not in result + assert 'HIGH' in result + assert 'CVSS 7.5' in result + + def test_medium_severity(self): + result = github_helpers.format_vulnerability_header( + 'CVE-2021-12345', + 'medium', + cvss_score=5.0 + ) + assert '๐ŸŸก' in result + assert 'MEDIUM' in result + assert 'CVSS 5.0' in result + + def test_low_severity(self): + result = github_helpers.format_vulnerability_header( + 'CVE-2021-67890', + 'low' + ) + assert 'โšช' in result + assert 'LOW' in result + + def test_empty_vuln_id(self): + result = github_helpers.format_vulnerability_header( + '', + 'critical', + cvss_score=10.0 + ) + assert 'Unknown' in result + assert 'CRITICAL' in result + assert 'CVSS 10.0' in result