Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
320 changes: 320 additions & 0 deletions .github/workflows/audit.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
@@ -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"
21 changes: 21 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
@@ -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.