Skip to content
Merged
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
45 changes: 45 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Deploy site to GitHub Pages

on:
push:
branches: [main]
paths: ['web/**', '.github/workflows/pages.yml']
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci
working-directory: web
# Project pages are served from /<repo>/, so set the base path.
- run: VITE_BASE="/forge/" npm run build
working-directory: web
- uses: actions/upload-pages-artifact@v3
with:
path: web/dist

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- id: deploy
uses: actions/deploy-pages@v4
2 changes: 2 additions & 0 deletions app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ default_permissions:
metadata: read
# Read CI/check results when verifying.
checks: read
# Read Dependabot alerts (live CVE data from GitHub's Advisory Database).
vulnerability_alerts: read
71 changes: 71 additions & 0 deletions src/github/dependabot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { ReviewFinding } from './review.js';
import type { OctokitLike } from './pr.js';

/**
* Map GitHub Dependabot alerts (live data from the GitHub Advisory Database) to
* our normalized findings. Pure + testable.
*/
/**
* Neutralize Markdown control chars so untrusted API text can't break or inject
* into the rendered comment. Escapes the full set of GFM specials (incl. () # + ! .)
* for defense-in-depth, and flattens newlines.
*/
function md(s: unknown): string {
return String(s ?? '')
.replace(/[\\`*_{}[\]()#+.!~<>|=-]/g, '\\$&')
.replace(/\r?\n/g, ' ');
}
Comment on lines +13 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low · 🛡️ Security · CWE-79

Incomplete Markdown Neutralization

The md() function is used to sanitize text from the Dependabot API before it's included in a Markdown comment on GitHub. The function's aim is to prevent Markdown injection by escaping special characters. However, the character set it escapes is incomplete. It misses several characters with special meaning in Markdown, such as parentheses (), hash (#), and exclamation mark (!).

While current usage doesn't appear to create a vulnerability, if future code changes were to place sanitized text inside a different Markdown structure (e.g., inside a link [text](url)), this could lead to injection or broken output. A more comprehensive sanitization provides better defense-in-depth.

Suggested change
function md(s: unknown): string {
return String(s ?? '').replace(/[`*_~<>[\]|\\]/g, '\\$&').replace(/\r?\n/g, ' ');
}
function md(s: unknown): string {
return String(s ?? '').replace(/[\\`*_{}\[\]()#+-.!~<>|]/g, '\\$&').replace(/\r?\n/g, ' ');
}


export function mapAlertsToFindings(alerts: unknown[]): ReviewFinding[] {
const out: ReviewFinding[] = [];
for (const raw of alerts) {
const a = raw as any;
if (a?.state && a.state !== 'open') continue;
const adv = a?.security_advisory ?? {};
const vuln = a?.security_vulnerability ?? {};
const pkg = a?.dependency?.package ?? {};
const sev = (adv.severity as string)?.toLowerCase();
const severity: ReviewFinding['severity'] =
sev === 'critical' || sev === 'high' || sev === 'medium' || sev === 'low' ? sev : 'medium';
const id = adv.cve_id || adv.ghsa_id || 'advisory';
const patched = vuln?.first_patched_version?.identifier;
out.push({
file: a?.dependency?.manifest_path || 'dependencies',
startLine: 1,
endLine: 1,
lens: 'security',
severity,
category: `Dependabot: ${id}`,
title: `Vulnerable dependency: ${md(pkg.name) || 'unknown'}`,
body:
`${md(adv.summary) || 'Known vulnerability in a dependency.'}\n\n` +
`Package: \`${md(pkg.name) || '?'}\` (${md(pkg.ecosystem) || '?'}). ` +
`Affected: ${md(vuln?.vulnerable_version_range) || '?'}. ` +
(patched ? `Fixed in **${md(patched)}** — upgrade to it or later.` : 'No patched version yet.') +
(typeof a?.html_url === 'string' ? `\n\n${a.html_url}` : ''),
});
Comment on lines +40 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low · 🔧 Quality · Improper Output Neutralization

Unsanitized Dependabot API data is used in Markdown report

The mapAlertsToFindings function constructs a Markdown string for the finding's body using raw data from the Dependabot API response (e.g., pkg.name, adv.summary). It does not sanitize or escape potential Markdown control characters (like backticks, newlines, etc.) in these fields. If a package name or vulnerability summary from the API contains these characters, it could break the formatting of the final GitHub comment, making it hard to read or even misleading. While the data comes from GitHub's API and is likely safe from XSS, this can lead to garbled output (a form of injection).

Suggested change
body:
`${adv.summary ?? 'Known vulnerability in a dependency.'}\n\n` +
`Package: \`${pkg.name ?? '?'}\` (${pkg.ecosystem ?? '?'}). ` +
`Affected: ${vuln?.vulnerable_version_range ?? '?'}. ` +
(patched ? `Fixed in **${patched}** — upgrade to it or later.` : 'No patched version yet.') +
(a?.html_url ? `\n\n${a.html_url}` : ''),
});
`Package: \`${(pkg.name ?? '?').replace(/`/g, '\\`')}\` (${pkg.ecosystem ?? '?'}). `

}
return out;
}

/**
* Fetch open Dependabot alerts for the repo (best-effort). Returns [] if the
* feature is disabled, not permitted, or unavailable — never throws.
*/
export async function fetchDependabotFindings(
octokit: OctokitLike,
owner: string,
repo: string,
log: (msg: string) => void = () => {},
): Promise<ReviewFinding[]> {
try {
if (!octokit.rest.dependabot?.listAlertsForRepo) return [];
const res = await octokit.rest.dependabot.listAlertsForRepo({ owner, repo, state: 'open', per_page: 50 });
const findings = mapAlertsToFindings(res.data);
if (findings.length) log(`ingested ${findings.length} Dependabot alert(s)`);
return findings;
} catch (err) {
log(`Dependabot alerts unavailable: ${(err as Error).message}`);
return [];
}
}
6 changes: 6 additions & 0 deletions src/github/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { buildRepoMap } from '../agent/repomap.js';
import { buildIssueContent, buildReviewContent, type CommentLike } from './context.js';
import { buildReviewPayload, parseFindings, parseDiffValidLines, renderAuditReport } from './review.js';
import { parseSarif } from './sarif.js';
import { fetchDependabotFindings } from './dependabot.js';
import { realWorkspace, type RepoRef, type WorkspacePort } from './workspace.js';
import {
composeFixPrBody,
Expand Down Expand Up @@ -361,6 +362,9 @@ async function doPrReview(

const findings = parseFindings(result.finalText);

// Merge live Dependabot alerts (current CVEs from GitHub's Advisory Database).
findings.push(...(await fetchDependabotFindings(octokit, args.owner, args.repo, log)));

// Optionally merge static-analysis (SARIF) findings, e.g. from CodeQL.
if (deps.sarifPath) {
try {
Expand Down Expand Up @@ -526,6 +530,8 @@ async function doAudit(
onEvent: (e) => e.type === 'tool' && log(`tool: ${e.name}`),
});
const findings = parseFindings(result.finalText);
// Merge live Dependabot alerts (current CVEs from GitHub's Advisory Database).
findings.push(...(await fetchDependabotFindings(octokit, args.owner, args.repo, log)));
await octokit.rest.issues.updateComment({
owner: args.owner,
repo: args.repo,
Expand Down
3 changes: 3 additions & 0 deletions src/github/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export interface OctokitLike {
repos: {
listCommits(params: Record<string, unknown>): Promise<{ data: Array<{ commit: { message: string } }> }>;
};
dependabot?: {
listAlertsForRepo(params: Record<string, unknown>): Promise<{ data: unknown[] }>;
};
};
request(route: string, params: Record<string, unknown>): Promise<{ data: unknown }>;
}
Expand Down
41 changes: 41 additions & 0 deletions tests/github/dependabot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { mapAlertsToFindings, fetchDependabotFindings } from '../../src/github/dependabot.js';

const alert = {
state: 'open',
html_url: 'https://github.com/o/r/security/dependabot/1',
dependency: { package: { ecosystem: 'npm', name: 'lodash' }, manifest_path: 'package.json' },
security_advisory: { ghsa_id: 'GHSA-xxxx', cve_id: 'CVE-2021-23337', summary: 'Command injection in lodash', severity: 'high' },
security_vulnerability: { vulnerable_version_range: '< 4.17.21', first_patched_version: { identifier: '4.17.21' } },
};

describe('mapAlertsToFindings', () => {
it('maps an open Dependabot alert to a finding', () => {
const [f] = mapAlertsToFindings([alert]);
expect(f).toMatchObject({ lens: 'security', severity: 'high', file: 'package.json', category: 'Dependabot: CVE-2021-23337' });
expect(f.title).toContain('lodash');
// version dots are markdown-escaped (renders as 4.17.21 on GitHub)
expect(f.body).toContain('4\\.17\\.21');
});

it('skips non-open alerts and defaults unknown severity to medium', () => {
expect(mapAlertsToFindings([{ ...alert, state: 'fixed' }])).toHaveLength(0);
const [f] = mapAlertsToFindings([{ ...alert, security_advisory: { ...alert.security_advisory, severity: 'weird' } }]);
expect(f.severity).toBe('medium');
});
});

describe('fetchDependabotFindings', () => {
it('returns [] (no throw) when the endpoint errors or is absent', async () => {
const octokit: any = { rest: {} };
expect(await fetchDependabotFindings(octokit, 'o', 'r')).toEqual([]);
const erroring: any = { rest: { dependabot: { listAlertsForRepo: async () => { throw new Error('403'); } } } };
expect(await fetchDependabotFindings(erroring, 'o', 'r')).toEqual([]);
});

it('returns findings when alerts are available', async () => {
const octokit: any = { rest: { dependabot: { listAlertsForRepo: async () => ({ data: [alert] }) } } };
const findings = await fetchDependabotFindings(octokit, 'o', 'r');
expect(findings).toHaveLength(1);
});
});
2 changes: 2 additions & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
13 changes: 13 additions & 0 deletions web/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ShipIT Forge — autonomous GitHub coding agent</title>
<meta name="description" content="Autonomous GitHub coding agent: fixes issues, opens PRs, reviews code with a security lens, auto-fixes CI, and audits your repo. Bring your own model." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
Loading