mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-12 04:01:35 +00:00
* fix(ci): drop --ignore from Safety check, filter accepted CVEs in jq instead The follow-up to #1370: adding `--ignore SFTY-20260120-40557` to the `safety check` invocation reintroduced the exact crash #1131/#1157 had just fixed - "Unhandled exception happened: 'cuda-toolkit'" - but only once Safety actually has a live vulnerability match to apply the ignore against (the plain, un-ignored scan against the same requirements-ci.txt had already succeeded and correctly reported that same match on main, per the run right before this one). I couldn't reproduce this locally: my local Safety installation doesn't surface the live cuda-toolkit CVE match at all (its open-source vulnerability DB appears to lag CI's), so --ignore never had a real match to crash on in my testing. That's on me - I should have caught that my "0 vulnerabilities" local result meant the DB hadn't even seen the finding yet, not that the fix worked. Since I can't safely iterate against Safety's own --ignore path without live-DB access, this moves the "should we still fail on ID X" decision out of Safety entirely: run the plain scan (the one path an actual CI run has now proven doesn't crash), then filter the accepted vulnerability ID out of the report ourselves in jq before counting/printing. Verified the jq expression directly against a synthetic report shaped like a real one (id present + one other unrelated id): filters exactly the intended entry, and - as a bonus - iterating over a null/missing "vulnerabilities" key with jq now raises inside jq the way the existing guard comment always assumed it did, rather than silently coming back as 0. * fix(ci): apply the accepted-CVE exclusion list to the PR comment too Qodo caught a real gap on this PR: the jq-based exclusion I added only covers the CI gate (the VULNS count and the failure-path detail print). The "Comment PR with Security Results" step reads safety-report.json independently in its own JS, with no filtering at all, so a PR touching only the accepted cuda-toolkit CVE would still get a comment saying "Found 1" even though the gate itself correctly treats it as non-actionable and passes. Export IGNORED_VULN_IDS via $GITHUB_ENV from the shell step so the JS step can read the same list, and filter data.vulnerabilities there before rendering - with a footnote naming what was excluded and why, so the comment stays transparent about the accepted finding rather than just silently hiding it. Verified the JS logic standalone against two synthetic reports: one with the accepted CVE plus an unrelated real one (shows only the real one, plus the footnote), and one with only the accepted CVE (shows "No findings" plus the footnote, rather than misleadingly looking identical to a clean scan with no explanation).
297 lines
14 KiB
YAML
297 lines
14 KiB
YAML
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.
|
|
#
|
|
# Scan requirements-ci.txt directly instead of the installed environment
|
|
# to avoid crashes from packages like cuda-toolkit that Safety cannot
|
|
# parse. This also ensures we're auditing the declared dependency tree
|
|
# rather than transitive dependencies of the security tooling itself.
|
|
safety check --file requirements-ci.txt --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..."
|
|
|
|
# Vulnerability IDs reviewed and accepted as non-actionable for this
|
|
# project. Filtered out here with jq rather than passed to Safety's
|
|
# own --ignore flag: --ignore crashes ("Unhandled exception happened:
|
|
# 'cuda-toolkit'") when it has to apply itself against a live-matched
|
|
# vulnerability for cuda-toolkit, apparently the same class of
|
|
# unguarded dependency-graph lookup that broke the plain environment
|
|
# scan (see git history on this file). The un-ignored scan above is
|
|
# the one path confirmed - by an actual CI run - not to crash even
|
|
# with a live cuda-toolkit match, so all filtering happens after the
|
|
# fact in jq instead of inside Safety.
|
|
#
|
|
# - SFTY-20260120-40557 (CVE-2025-33228): cuda-toolkit<13.1.0. torch
|
|
# 2.13.0 (latest available; no newer release exists) hard-pins
|
|
# cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,
|
|
# cusparse,nvjitlink,nvrtc,nvtx]==13.0.3 on Linux - not a version we
|
|
# control. The CVE is OS command injection in NVIDIA Nsight
|
|
# Systems' gfx_hotspot recipe (process_nsys_rep_cli.py), requiring
|
|
# manual invocation with an attacker-supplied string; unreachable
|
|
# from Semantica, and Nsight Systems isn't among the extras torch
|
|
# requests above. Re-evaluate once torch pins a patched
|
|
# cuda-toolkit.
|
|
IGNORED_VULN_IDS="SFTY-20260120-40557"
|
|
|
|
# Exported so the "Comment PR with Security Results" step below can
|
|
# apply the same exclusion list to the raw report - it reads
|
|
# safety-report.json independently in JS, so without this the PR
|
|
# comment would show the accepted CVE as a live finding even though
|
|
# this gate correctly treats it as non-actionable.
|
|
echo "IGNORED_VULN_IDS=$IGNORED_VULN_IDS" >> "$GITHUB_ENV"
|
|
|
|
# No []? / || echo "0" fallback on a missing/null "vulnerabilities"
|
|
# key: iterating over null raises inside jq, leaving VULNS empty, so
|
|
# guard 2 below catches it rather than silently treating a broken
|
|
# report as zero.
|
|
VULNS=$(jq --arg ignored "$IGNORED_VULN_IDS" '
|
|
($ignored | split(",")) as $ignore_list
|
|
| [.vulnerabilities[] | select(.vulnerability_id as $id | ($ignore_list | index($id)) | not)]
|
|
| 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 --arg ignored "$IGNORED_VULN_IDS" -r '
|
|
($ignored | split(",")) as $ignore_list
|
|
| .vulnerabilities[] | select(.vulnerability_id as $id | ($ignore_list | index($id)) | not)
|
|
| "- \(.package_name)==\(.analyzed_version): \(.vulnerability_id) (\(.CVE // "no CVE assigned"))"
|
|
' safety-report.json || true
|
|
exit 1
|
|
else
|
|
echo "✅ No actionable security vulnerabilities found (ignored: $IGNORED_VULN_IDS)"
|
|
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 <details> 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('<details>', '<summary>Show all findings</summary>', '');
|
|
lines.push(...items);
|
|
lines.push('', '</details>');
|
|
} else {
|
|
lines.push(...shown);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// Mirrors the shell step's own IGNORED_VULN_IDS (passed through
|
|
// $GITHUB_ENV) so an accepted, non-actionable CVE that the CI
|
|
// gate already excluded doesn't reappear here as a live finding -
|
|
// this reads the same raw, unfiltered safety-report.json.
|
|
const ignoredVulnIds = (process.env.IGNORED_VULN_IDS || '')
|
|
.split(',')
|
|
.map((id) => id.trim())
|
|
.filter(Boolean);
|
|
|
|
const safetySection = renderSection(
|
|
'Safety — dependency vulnerabilities',
|
|
'safety-report.json',
|
|
(data) => (data.vulnerabilities || [])
|
|
.filter((v) => !ignoredVulnIds.includes(v.vulnerability_id))
|
|
.map(
|
|
(v) => `- \`${v.package_name}==${v.analyzed_version}\`: ${v.vulnerability_id}` +
|
|
(v.CVE ? ` (${v.CVE})` : '') + ` — ${v.advisory || 'no advisory text'}`
|
|
)
|
|
) + (ignoredVulnIds.length
|
|
? `\n\n_Excluded as accepted, non-actionable findings: ${ignoredVulnIds.join(', ')} — see the workflow file's inline comments for why._`
|
|
: '');
|
|
|
|
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');
|
|
}
|