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
2 changes: 1 addition & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
node_modules/
dist/
coverage/

scripts/
.eslintrc.js
index.*
commitlint.config.js
14 changes: 14 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,18 @@ module.exports = {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
overrides: [
{
// scripts/ are plain Node.js JS files not covered by tsconfig.json,
// so disable typed linting rules for them
files: ['scripts/**/*.js'],
parserOptions: {
project: null,
},
rules: {
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-misused-promises': 'off',
},
},
],
};
74 changes: 74 additions & 0 deletions .github/workflows/trivy-remediation.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Trivy Security Remediation

on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:

permissions:
contents: write
pull-requests: write

env:
CONFIG_USERNAME: ${{ vars.GIT_COMMIT_USERNAME }}
CONFIG_EMAIL: ${{ vars.GIT_COMMIT_EMAIL }}

jobs:
security-remediation:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: master
fetch-depth: 0
persist-credentials: true
token: ${{ secrets.RELEASE_COMMIT_GH_PAT }}

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22

- name: Configure Git
run: |
git config --global user.name "$CONFIG_USERNAME"
git config --global user.email "$CONFIG_EMAIL"

- name: Install dependencies
run: npm ci --ignore-scripts

- name: Install Trivy
uses: aquasecurity/setup-trivy@v0.2.0

- name: Run remediation workflow
run: bash scripts/trivy-remediation.sh

- name: Stage dependency changes
run: |
git add package.json package-lock.json || true

- name: Check for changes
id: changes
run: |
if ! git diff --cached --quiet; then
echo "changed=true" >> $GITHUB_OUTPUT
else
echo "changed=false" >> $GITHUB_OUTPUT
fi

- name: Create Pull Request
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.RELEASE_COMMIT_GH_PAT }}
branch: security/trivy-remediation
base: master
delete-branch: true
add-paths: |
package.json
package-lock.json
commit-message: 'fix(security): automated Trivy remediation'
title: 'fix(security): Automated Trivy vulnerability remediation'
body: Automated fix for HIGH and CRITICAL vulnerabilities detected by Trivy.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"jsdom": "^21.0.0",
"nyc": "^17.1.0",
"semantic-release": "^25.0.1",
"semver": "^7.5.4",
"simple-git": "^3.15.1",
"source-map-support": "^0.5.21",
"typescript": "~5.2.2"
Expand Down
15 changes: 15 additions & 0 deletions scripts/audit-remediation.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/bin/bash

set -e

echo "Running npm audit fix"

npm audit fix --ignore-scripts || true

echo "Updating lockfile"

npm i --package-lock-only --ignore-scripts

echo "Installing dependencies"

npm ci --ignore-scripts
201 changes: 201 additions & 0 deletions scripts/dependency-fix.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
const fs = require('fs');
const {execSync} = require('child_process');
const semver = require('semver');

console.log('Starting Trivy dependency remediation');

const report = JSON.parse(fs.readFileSync('trivy-report.json', 'utf8'));
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));

const processed = new Set();
const newOverrides = {};

let deps = {
...pkg.dependencies,
...pkg.devDependencies,
};

let dependencyTree = {};

function buildDependencyTree() {
try {
const tree = execSync('npm ls --json', {encoding: 'utf8'});
dependencyTree = JSON.parse(tree);
} catch (e) {
dependencyTree = {};
}
}

function refreshDependencies() {
const updated = JSON.parse(fs.readFileSync('package.json', 'utf8'));

deps = {
...updated.dependencies,
...updated.devDependencies,
};

buildDependencyTree();
}

function isDirect(name) {
return deps[name] !== undefined;
}

function cleanVersion(v) {
if (!v) return null;
return v.replace('^', '').replace('~', '');
}

function safeUpgrade(current, target) {
const c = semver.coerce(current);
const t = semver.coerce(target);

if (!c || !t) return false;

const diff = semver.diff(c, t);

return diff === 'patch' || diff === 'minor';
}

function findInstalledVersion(pkgName) {
function search(node) {
if (!node.dependencies) return null;

for (const [name, data] of Object.entries(node.dependencies)) {
if (name === pkgName) return data.version;

const nested = search(data);

if (nested) return nested;
}

return null;
}

return search(dependencyTree);
}

function findParentDependency(pkgName) {
function search(node) {
if (!node.dependencies) return null;

for (const [name, data] of Object.entries(node.dependencies)) {
if (data.dependencies && data.dependencies[pkgName]) {
return name;
}

const nested = search(data);

if (nested) return nested;
}

return null;
}

return search(dependencyTree);
}

buildDependencyTree();

for (const result of report.Results || []) {
if (result.Type !== 'npm') continue;

for (const vuln of result.Vulnerabilities || []) {
const name = vuln.PkgName;
const fixed = vuln.FixedVersion?.split(',')[0]?.trim();

if (!name || !fixed) continue;

if (processed.has(name)) continue;
processed.add(name);

console.log(`Processing vulnerability: ${name}`);

const installed = findInstalledVersion(name);

if (installed && semver.gte(installed, fixed)) {
console.log(`${name} already resolved to safe version ${installed}`);
continue;
}

const current = isDirect(name) ? cleanVersion(deps[name]) : null;

if (isDirect(name)) {
console.log(`Direct dependency detected: ${name}`);

if (!safeUpgrade(current, fixed)) {
console.warn(
`Skipping unsafe upgrade ${name} (${current} -> ${fixed})`,
);
} else {
try {
console.log(`Updating direct dependency ${name} -> ${fixed}`);

execSync(`npm i ${name}@${fixed} --ignore-scripts`, {
stdio: 'inherit',
});

refreshDependencies();

const updatedVersion = findInstalledVersion(name);

if (updatedVersion && semver.gte(updatedVersion, fixed)) {
console.log(`${name} resolved via direct upgrade`);
continue;
}
} catch {
console.log(`Direct upgrade failed for ${name}`);
}
}
}

const parent = findParentDependency(name);

if (parent && deps[parent]) {
console.log(`Attempting parent dependency upgrade: ${parent}`);

try {
const parentVersion = cleanVersion(deps[parent]);

if (parentVersion) {
execSync(`npm i ${parent}@^${parentVersion} --ignore-scripts`, {
stdio: 'inherit',
});

refreshDependencies();

const updatedVersion = findInstalledVersion(name);

if (updatedVersion && semver.gte(updatedVersion, fixed)) {
console.log(`${name} resolved via parent upgrade (${parent})`);
continue;
}
}
} catch {
console.log(`Parent upgrade failed for ${parent}`);
}
}

if (!pkg.overrides || !pkg.overrides[name]) {
console.log(`Adding override for ${name}`);

newOverrides[name] = `^${fixed}`;
}
}
}

const updatedPkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));

if (!updatedPkg.overrides) updatedPkg.overrides = {};

Object.assign(updatedPkg.overrides, newOverrides);

fs.writeFileSync('package.json', JSON.stringify(updatedPkg, null, 2) + '\n');

if (Object.keys(newOverrides).length > 0) {
console.log('Updating lockfile for overrides');

execSync('npm i --package-lock-only --ignore-scripts', {stdio: 'inherit'});
}

console.log('Trivy dependency remediation completed');
28 changes: 28 additions & 0 deletions scripts/trivy-remediation.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/bin/bash

set -e

echo "Step 1: Run Trivy scan"
bash scripts/trivy-scan.sh

echo "Step 2: Run npm audit fix"
bash scripts/audit-remediation.sh

echo "Step 3: Re-scan vulnerabilities"

trivy fs \
--severity HIGH,CRITICAL \
--format json \
-o trivy-after.json \
.

mv trivy-after.json trivy-report.json

echo "Step 4: Fix remaining vulnerabilities"

node scripts/dependency-fix.js

echo "Step 5: Update lockfile"

npm i --package-lock-only --ignore-scripts
npm ci --ignore-scripts
14 changes: 14 additions & 0 deletions scripts/trivy-scan.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/bin/bash

set -e

echo "Running Trivy scan..."

trivy fs \
--scanners vuln \
--severity HIGH,CRITICAL \
--format json \
-o trivy-report.json \
.

echo "Trivy scan completed"
Loading