name: Security Scan on: schedule: - cron: '30 1 * * 1,4' # Mon/Thu 7 AM IST push: branches: [main] paths-ignore: - 'docs/**' - 'mkdocs.yml' - 'requirements-docs.txt' - '**/*.md' pull_request: branches: [main] paths-ignore: - 'docs/**' - 'mkdocs.yml' - 'requirements-docs.txt' - '**/*.md' permissions: contents: read jobs: security-scan: runs-on: ubuntu-latest permissions: contents: read security-events: write actions: read # Needed for the "Comment PR with Security Results" step below. Safe on # pull_request (not pull_request_target): GitHub always forces a # read-only token for PRs from forks regardless of this permission. pull-requests: write steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.11' - name: Install dependencies run: | pip install -r .github/requirements/bootstrap.txt --require-hashes # Install the pinned dependency set FIRST so Safety scans Semantica's # exact CI/release dependency tree (requirements-ci.txt is generated # from pyproject.toml extras, so this covers the project's real deps). pip install -r requirements-ci.txt --require-hashes # Tooling AFTER the pinned set: installing safety/bandit/semgrep/jq # first lets the pinned requirements overwrite their transitive deps # (e.g. rich), which breaks the safety CLI at runtime. pip install -r .github/requirements/security-scan-tools.txt --require-hashes - name: Run Safety Check (Package Vulnerabilities) run: | # NOTE: Safety 3.x repurposed --output to select a console format # (json/text/screen/...), not a file path. Writing JSON to a file # now requires --save-json; the previous `--output safety-report.json` # usage was silently invalid and never produced a report. safety check --save-json safety-report.json || true # Guard 1: fail loudly if Safety exited before writing a report at all # (network error, API auth failure, tool crash). Without this check a # missing or empty file causes jq to fall back to "0", making a broken # scanner indistinguishable from a clean scan. if [ ! -s safety-report.json ]; then echo "::error::Safety scan produced no report (safety-report.json is missing or empty). Treating as failure — check for network errors, API auth failures, or Safety crashes in the logs above." exit 1 fi echo "Checking for package vulnerabilities..." # No || echo "0" fallback: if jq fails (malformed JSON, missing key, # vulnerabilities:null) VULNS will be empty or "null" so guard 2 below # catches it rather than silently treating the broken report as zero. VULNS=$(jq '.vulnerabilities | length' safety-report.json 2>/dev/null) # Guard 2: ensure VULNS is a non-negative integer before the -gt # comparison. "null" (missing/null key) or "" (jq parse failure) would # cause bash's -gt to throw an arithmetic error and fall through to the # success branch — the same silent-pass bug as a missing file. if ! [[ "$VULNS" =~ ^[0-9]+$ ]]; then echo "::error::Safety report exists but 'vulnerabilities' is missing or non-numeric (got: '${VULNS}'). The report may be malformed or Safety may have written an error-only JSON. Treating as failure." exit 1 fi if [ "$VULNS" -gt 0 ]; then echo "❌ Security vulnerabilities found: $VULNS" echo "CI will fail to prevent merging of vulnerable dependencies" echo "" echo "Vulnerability details:" jq -r '.vulnerabilities[] | "- \(.package_name)==\(.analyzed_version): \(.vulnerability_id) (\(.CVE // "no CVE assigned"))"' safety-report.json || true exit 1 else echo "✅ No security vulnerabilities found" fi - name: Run Bandit (Code Security Linter) run: | bandit -r semantica/ -f json -o bandit-report.json || true echo "Checking for HIGH severity security issues..." # Count HIGH severity issues HIGH_ISSUES=$(bandit -r semantica/ -f json -ll 2>/dev/null | jq -r '.results[]? | select(.issue_severity == "HIGH") | .test_name' 2>/dev/null | wc -l || echo "0") if [ "$HIGH_ISSUES" -gt 0 ]; then echo "❌ HIGH severity security issues found: $HIGH_ISSUES" echo "CI will fail to prevent merging of high-risk code" echo "" echo "High severity issues:" bandit -r semantica/ -ll | grep "Severity: High" -A 5 -B 1 || true exit 1 else echo "✅ No HIGH severity security issues found" fi - name: Run Semgrep (Static Analysis) run: | echo "Running Semgrep static analysis..." semgrep --config=auto --json --output=semgrep-report.json semantica/ || true # Run security-focused rules echo "Checking for security patterns..." SECURITY_ISSUES=$(semgrep --config=p/security --json semantica/ 2>/dev/null | jq '.results | length' 2>/dev/null || echo "0") if [ "$SECURITY_ISSUES" -gt 0 ]; then echo "⚠️ Security patterns found: $SECURITY_ISSUES" echo "Review these findings for potential improvements" semgrep --config=p/security semantica/ || true else echo "✅ No security patterns found" fi - name: Upload Security Reports uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: security-reports retention-days: 14 path: | safety-report.json bandit-report.json semgrep-report.json - name: Comment PR with Security Results if: github.event_name == 'pull_request' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const fs = require('fs'); // Renders one tool's findings as a section. `items` is already // the list of pre-formatted "- `thing` in `where`" strings; this // just handles the found/not-found/report-missing framing and // collapses long lists into a
block so the comment // doesn't turn into a wall of text. function renderSection(title, reportPath, parse) { let data; try { data = JSON.parse(fs.readFileSync(reportPath, 'utf8')); } catch (e) { return [ `### ${title}`, `⚠️ No report found at \`${reportPath}\` — the scan may have failed before producing output. Check the job logs.`, ].join('\n'); } const items = parse(data); if (items.length === 0) { return [`### ${title}`, `✅ No findings.`].join('\n'); } const lines = [`### ${title}`, `Found **${items.length}**.`, '']; const shown = items.slice(0, 15); if (items.length > 15) { lines.push('
', 'Show all findings', ''); lines.push(...items); lines.push('', '
'); } else { lines.push(...shown); } return lines.join('\n'); } const safetySection = renderSection( 'Safety — dependency vulnerabilities', 'safety-report.json', (data) => (data.vulnerabilities || []).map( (v) => `- \`${v.package_name}==${v.analyzed_version}\`: ${v.vulnerability_id}` + (v.CVE ? ` (${v.CVE})` : '') + ` — ${v.advisory || 'no advisory text'}` ) ); const banditSection = renderSection( 'Bandit — HIGH-severity code issues', 'bandit-report.json', (data) => (data.results || []) .filter((issue) => issue.issue_severity === 'HIGH') .map((issue) => `- \`${issue.test_name}\` in \`${issue.filename}:${issue.line_number}\``) ); const semgrepSection = renderSection( 'Semgrep — static analysis patterns', 'semgrep-report.json', (data) => (data.results || []).map( (issue) => `- \`${issue.check_id}\` in \`${issue.path}:${issue.start?.line ?? '?'}\`` ) ); const comment = [ '# 🔒 Security Scan Results', '', safetySection, '', banditSection, '', semgrepSection, '', '---', '', '*This security scan runs automatically on source-code PRs and bi-weekly (skipped for doc/markdown-only changes).*', '', '📊 **Security Policy**: CI fails on Safety vulnerabilities and Bandit HIGH-severity findings. Semgrep findings above are informational and do not block merge.', ].join('\n'); try { await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: comment, }); console.log('✅ Security comment posted successfully'); } catch (error) { console.log('⚠️ Could not post security comment:', error.message); console.log('📋 Security scan results saved to artifacts'); }