diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..d534435 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,320 @@ +name: Dependency Audit + +on: + pull_request: + branches: [main, master] + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + audit: + name: npm and Cargo dependency audit + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Run npm audit + id: npm_audit + shell: bash + run: | + npm audit --audit-level=critical --json > npm-audit-report.json || true + node <<'NODE' + const fs = require('node:fs'); + const report = JSON.parse(fs.readFileSync('npm-audit-report.json', 'utf8')); + const critical = report.metadata?.vulnerabilities?.critical || 0; + fs.appendFileSync(process.env.GITHUB_OUTPUT, `critical=${critical}\n`); + console.log(`npm critical vulnerabilities: ${critical}`); + NODE + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo audit tools + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/cargo-audit + ~/.cargo/bin/cargo-deny + ~/.cargo/registry + ~/.cargo/git + key: cargo-audit-tools-${{ runner.os }}-${{ hashFiles('Cargo.toml', 'contracts/**/Cargo.toml', 'deny.toml') }} + restore-keys: | + cargo-audit-tools-${{ runner.os }}- + + - name: Install Cargo audit tools + shell: bash + run: | + command -v cargo-audit >/dev/null 2>&1 || cargo install cargo-audit --locked + command -v cargo-deny >/dev/null 2>&1 || cargo install cargo-deny --locked + + - name: Run cargo audit + id: cargo_audit + shell: bash + run: | + if [ ! -f Cargo.lock ]; then + cargo generate-lockfile + fi + cargo audit --json > cargo-audit-report.json || true + node <<'NODE' + const fs = require('node:fs'); + const report = JSON.parse(fs.readFileSync('cargo-audit-report.json', 'utf8')); + const advisories = report.vulnerabilities?.list || []; + + function cvssVectorScore(vector) { + const metrics = Object.fromEntries( + vector + .split('/') + .slice(1) + .map((part) => part.split(':')) + .filter(([key, value]) => key && value), + ); + + const av = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }[metrics.AV]; + const ac = { L: 0.77, H: 0.44 }[metrics.AC]; + const pr = { + U: { N: 0.85, L: 0.62, H: 0.27 }, + C: { N: 0.85, L: 0.68, H: 0.5 }, + }[metrics.S]?.[metrics.PR]; + const ui = { N: 0.85, R: 0.62 }[metrics.UI]; + const scope = metrics.S; + const conf = { H: 0.56, L: 0.22, N: 0 }[metrics.C]; + const integ = { H: 0.56, L: 0.22, N: 0 }[metrics.I]; + const avail = { H: 0.56, L: 0.22, N: 0 }[metrics.A]; + + if ( + [av, ac, pr, ui, conf, integ, avail].some((value) => value === undefined) || + !['U', 'C'].includes(scope) + ) { + throw new Error(`Unsupported CVSS vector: ${vector}`); + } + + const impactSubScore = 1 - (1 - conf) * (1 - integ) * (1 - avail); + const impact = scope === 'U' + ? 6.42 * impactSubScore + : 7.52 * (impactSubScore - 0.029) - 3.25 * Math.pow(impactSubScore - 0.02, 15); + const exploitability = 8.22 * av * ac * pr * ui; + if (impact <= 0) { + return 0; + } + const raw = scope === 'U' + ? Math.min(impact + exploitability, 10) + : Math.min(1.08 * (impact + exploitability), 10); + return Math.ceil(raw * 10) / 10; + } + + function isBlockingCargoAdvisory(item) { + const advisory = item.advisory || {}; + const severity = String(advisory.severity || '').toLowerCase(); + if (severity === 'high' || severity === 'critical') { + return true; + } + + const cvss = advisory.cvss; + if (typeof cvss === 'number') { + return cvss >= 7; + } + if (typeof cvss === 'string' && cvss.trim()) { + const trimmed = cvss.trim(); + if (trimmed.startsWith('CVSS:3.')) { + try { + return cvssVectorScore(trimmed) >= 7; + } catch { + return true; + } + } + const numeric = Number(trimmed); + if (Number.isFinite(numeric)) { + return numeric >= 7; + } + return true; + } + return false; + } + + const blocking = advisories.filter((item) => { + return isBlockingCargoAdvisory(item); + }); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `blocking=${blocking.length}\n`); + console.log(`cargo high/critical vulnerabilities: ${blocking.length}`); + NODE + + - name: Run cargo deny policy + shell: bash + run: cargo deny check licenses bans sources + + - name: Upload npm audit report + uses: actions/upload-artifact@v4 + if: always() + with: + name: npm-audit-report + path: npm-audit-report.json + if-no-files-found: error + + - name: Upload Cargo audit report + uses: actions/upload-artifact@v4 + if: always() + with: + name: cargo-audit-report + path: cargo-audit-report.json + if-no-files-found: error + + - name: Evaluate audit results + id: evaluate + if: always() + shell: bash + run: | + npm_critical="${{ steps.npm_audit.outputs.critical || '0' }}" + cargo_blocking="${{ steps.cargo_audit.outputs.blocking || '0' }}" + failed=false + if [ "$npm_critical" -gt 0 ] || [ "$cargo_blocking" -gt 0 ]; then + failed=true + fi + echo "failed=$failed" >> "$GITHUB_OUTPUT" + echo "npm critical vulnerabilities: $npm_critical" + echo "cargo high/critical vulnerabilities: $cargo_blocking" + + - name: Open scheduled audit issue + if: always() && github.event_name == 'schedule' && steps.evaluate.outputs.failed == 'true' + uses: actions/github-script@v7 + with: + script: | + const fs = require('node:fs'); + const today = new Date().toISOString().slice(0, 10); + + function npmFindings() { + if (!fs.existsSync('npm-audit-report.json')) return []; + const report = JSON.parse(fs.readFileSync('npm-audit-report.json', 'utf8')); + return Object.values(report.vulnerabilities || {}) + .filter((item) => item.severity === 'critical') + .map((item) => { + const advisories = (item.via || []) + .filter((via) => typeof via === 'object') + .map((via) => via.url || via.title || via.source) + .filter(Boolean) + .join(', '); + return `- npm: ${item.name} (${item.severity})${advisories ? ` - ${advisories}` : ''}`; + }); + } + + function cargoFindings() { + if (!fs.existsSync('cargo-audit-report.json')) return []; + const report = JSON.parse(fs.readFileSync('cargo-audit-report.json', 'utf8')); + + function cvssVectorScore(vector) { + const metrics = Object.fromEntries( + vector + .split('/') + .slice(1) + .map((part) => part.split(':')) + .filter(([key, value]) => key && value), + ); + + const av = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }[metrics.AV]; + const ac = { L: 0.77, H: 0.44 }[metrics.AC]; + const pr = { + U: { N: 0.85, L: 0.62, H: 0.27 }, + C: { N: 0.85, L: 0.68, H: 0.5 }, + }[metrics.S]?.[metrics.PR]; + const ui = { N: 0.85, R: 0.62 }[metrics.UI]; + const scope = metrics.S; + const conf = { H: 0.56, L: 0.22, N: 0 }[metrics.C]; + const integ = { H: 0.56, L: 0.22, N: 0 }[metrics.I]; + const avail = { H: 0.56, L: 0.22, N: 0 }[metrics.A]; + + if ( + [av, ac, pr, ui, conf, integ, avail].some((value) => value === undefined) || + !['U', 'C'].includes(scope) + ) { + throw new Error(`Unsupported CVSS vector: ${vector}`); + } + + const impactSubScore = 1 - (1 - conf) * (1 - integ) * (1 - avail); + const impact = scope === 'U' + ? 6.42 * impactSubScore + : 7.52 * (impactSubScore - 0.029) - 3.25 * Math.pow(impactSubScore - 0.02, 15); + const exploitability = 8.22 * av * ac * pr * ui; + if (impact <= 0) { + return 0; + } + const raw = scope === 'U' + ? Math.min(impact + exploitability, 10) + : Math.min(1.08 * (impact + exploitability), 10); + return Math.ceil(raw * 10) / 10; + } + + function isBlockingCargoAdvisory(item) { + const advisory = item.advisory || {}; + const severity = String(advisory.severity || '').toLowerCase(); + if (severity === 'high' || severity === 'critical') { + return true; + } + + const cvss = advisory.cvss; + if (typeof cvss === 'number') { + return cvss >= 7; + } + if (typeof cvss === 'string' && cvss.trim()) { + const trimmed = cvss.trim(); + if (trimmed.startsWith('CVSS:3.')) { + try { + return cvssVectorScore(trimmed) >= 7; + } catch { + return true; + } + } + const numeric = Number(trimmed); + if (Number.isFinite(numeric)) { + return numeric >= 7; + } + return true; + } + return false; + } + + return (report.vulnerabilities?.list || []) + .filter(isBlockingCargoAdvisory) + .map((item) => { + const advisory = item.advisory || {}; + const pkg = item.package?.name || 'unknown'; + const id = advisory.id || 'unknown advisory'; + const title = advisory.title || 'untitled advisory'; + return `- cargo: ${pkg} ${id} - ${title}`; + }); + } + + const findings = [...npmFindings(), ...cargoFindings()]; + const body = [ + 'The scheduled dependency audit found blocking vulnerabilities.', + '', + findings.length ? findings.join('\n') : '- See attached workflow audit artifacts for details.', + '', + 'Artifacts:', + '- npm-audit-report.json', + '- cargo-audit-report.json', + '', + 'Critical vulnerabilities should be triaged within 24 hours per docs/security.md.', + ].join('\n'); + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Security: dependency vulnerability found - ${today}`, + body, + }); + + - name: Fail on blocking vulnerabilities + if: steps.evaluate.outputs.failed == 'true' + run: exit 1 diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..e98fcbb --- /dev/null +++ b/deny.toml @@ -0,0 +1,21 @@ +[licenses] +confidence-threshold = 0.8 +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MIT", + "Unicode-3.0", + "Zlib", +] + +[bans] +multiple-versions = "deny" +wildcards = "deny" +highlight = "all" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..55b2226 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,21 @@ +# Security Response + +TariffShield dependency audits run on pull requests, on a daily schedule, and on manual dispatch. The audit workflow checks npm dependencies for critical vulnerabilities and Cargo dependencies for high or critical advisories. + +## Triage + +When the scheduled audit opens a security issue, assign an owner and review the attached `npm-audit-report.json` and `cargo-audit-report.json` artifacts. Confirm the affected package, advisory ID or CVE, reachable code path, available patched version, and whether the vulnerable package is used in production. + +## Response SLA + +Critical dependency vulnerabilities must have an approved mitigation plan within 24 hours. The preferred mitigation is upgrading or pinning to a fixed version. If no fix is available, document the compensating control, exposure, and follow-up date in the tracking issue. + +High Cargo advisories should be handled with the same process unless maintainers document that the affected crate is not built or reachable in deployed artifacts. + +## Patch Process + +1. Open a focused dependency update PR. +2. Include the advisory ID or CVE in the PR body. +3. Attach local audit output or link to the failing scheduled workflow. +4. Run the relevant application tests and audit command again. +5. Close the scheduled audit issue only after the workflow passes or the mitigation is documented.