diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index f10a9b2..6dcb50c 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -11,12 +11,13 @@ permissions: security-events: write # SARIF → Code Scanning (upload-sarif default true) pull-requests: write # sticky PR comment on the pull_request job -# TEMPORARY PIN — remove once an engine release with `mcp`-category support is the -# `latest` release. The released engine v0.1.2 predates the `mcp` rule category -# that trustabl-rules now ships, so `latest` engine + latest rules fails to load -# ("unknown category mcp"). Every selftest job is pinned to v0.1.2 + the `pre-mcp` -# rules tag (a known-compatible pair) so CI exercises the ACTION, not the upstream -# engine↔rules drift. Engine fix: trustabl/trustabl#12. +# Engine pin — every selftest job pins `version: v0.1.4` (with the engine's default +# rules) rather than `latest`, so CI is deterministic and exercises the ACTION +# against a known engine. v0.1.4 is the release that introduced the finding +# line-range shape (start_line/end_line) this action consumes, so it validates that +# path end to end. It also supports the `mcp` rule category natively and loads any +# newer rule leniently, so default rules need no compatibility pin. Bump this when a +# newer engine release should gate the action. jobs: scan-remote-target: @@ -31,8 +32,7 @@ jobs: continue-on-error: true with: target: "https://github.com/openai/openai-agents-python" - version: "v0.1.2" # pinned — see note above - rules-ref: "pre-mcp" # pinned — see note above + version: "v0.1.4" # pinned — see note above severity-threshold: "none" risk-score-threshold: "0" comment-on-pr: "false" # remote target; nothing to comment about on this PR @@ -58,8 +58,7 @@ jobs: - uses: ./ with: target: "." - version: "v0.1.2" - rules-ref: "pre-mcp" + version: "v0.1.4" comment-on-pr: "false" scan-pr-surfaces: @@ -72,7 +71,6 @@ jobs: - uses: ./ with: target: "." - version: "v0.1.2" - rules-ref: "pre-mcp" + version: "v0.1.4" severity-threshold: "none" # plumbing check only; don't gate the selftest PR risk-score-threshold: "0" diff --git a/CHANGELOG.md b/CHANGELOG.md index 905a928..67eced5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] — 2026-06-09 + +Tracks trustabl engine **v0.1.4**: consumes the new finding line-range shape and +adds an opt-in dependency CVE scan. + +### Added + +- **`vuln-scan` input** (default `false`). Passes `--vuln-scan`, so trustabl matches + declared dependencies against a pinned OSV snapshot and reports known CVEs. Each + match is a finding, so it flows through the readiness score, gating, inline + annotations, and the Security tab like any other — plus a dependency headline + (dependencies scanned / known vulnerabilities) in the console panel, Step + Summary, and PR comment. + +### Changed + +- **Finding line ranges.** The action reads the engine's `start_line`/`end_line` + (engine ≥ v0.1.4) and renders multi-line inline annotations across the finding's + full span. The legacy single `line` field is still read as a fallback, so the + action stays correct against older pinned engines. +- **`skill` scope** added to the typed scope / surface-kind unions, matching the + engine's five detection scopes (tool, agent, subagent, skill, repo). +- **`MIN_ENGINE_VERSION` is now `v0.1.3`** (previously an unset placeholder) — the + release that introduced single-scan dual output, Code-Scanning-valid SARIF, and + the projected-scores headroom ladder. Older engines still run via the two-scan + fallback with a soft upgrade warning. + +### Fixed + +- **Inline annotations no longer collapse to the top of the file** against engine + v0.1.4. The engine renamed the finding `line` field to `start_line`/`end_line`; + the action still read `line`, so each annotation lost its line number. It now + resolves the range from either shape. + + ## [0.2.0] — 2026-06-04 A full rewrite from the bash composite action to a **node20 TypeScript action**, diff --git a/README.md b/README.md index 89ab5b9..13a218a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ Trustabl — open-source tooling for production-ready agentic tools +

+ # Trustabl Action A GitHub Action that runs [trustabl](https://github.com/trustabl/trustabl) — the @@ -12,6 +16,9 @@ Agents SDK, Google ADK, MCP) — and surfaces the results where you work: - **Status-check gating.** Optionally fail the job on a risk-score or severity threshold so it can be a required check. - **A readiness panel** in the run log and the Step Summary. +- **Optional dependency CVE scan** (`vuln-scan: true`) — matches your declared + dependencies against a pinned OSV snapshot and reports known CVEs as findings, + so they appear on every surface (score, gate, annotations, Security tab). It downloads the official `trustabl` release binary (sha256-verified against the release `checksums.txt`), tool-caches it, scans your checkout, and reports. @@ -70,6 +77,7 @@ jobs: with: # every input is optional # detectors: openai_sdk # limit SDKs: claude_sdk,openai_sdk,google_adk,openshell # version: latest # trustabl release to run; pin e.g. v0.5.0 for reproducible CI + # vuln-scan: true # also scan dependencies for known CVEs (OSV) # severity-threshold: high # fail if any finding >= level (none|low|medium|high|critical) # risk-score-threshold: 70 # fail if risk (100 - readiness) >= N (0 disables) # comment-on-pr: true # sticky PR summary comment @@ -81,7 +89,7 @@ jobs: ## Pinned + gated ```yaml -- uses: trustabl/trustabl-action@v0.2.0 +- uses: trustabl/trustabl-action@v0.3.0 with: version: v0.5.0 detectors: claude_sdk,openai_sdk @@ -98,6 +106,7 @@ jobs: | `version` | `latest` | trustabl release tag (e.g. `v0.5.0`) or `latest`. | | `detectors` | _(all)_ | Comma-separated subset: `claude_sdk,openai_sdk,google_adk,openshell`. | | `strict` | `false` | Pass `--strict` (fail on any finding). | +| `vuln-scan` | `false` | Match dependencies against a pinned OSV snapshot; report known CVEs as findings. | | `rules-ref` | _(default)_ | Pin a `trustabl-rules` git ref. | | `rules-repo` | _(default)_ | Override the `trustabl-rules` source repo. | | `upload-sarif` | `true` | Upload SARIF to Code Scanning. Needs `security-events: write`. | @@ -156,7 +165,7 @@ After a run, open the run page and find the **`trustabl-scan-results`** artifact ## Versioning -- Pin a release: `uses: trustabl/trustabl-action@v0.2.0`. +- Pin a release: `uses: trustabl/trustabl-action@v0.3.0`. - Or track the line: `uses: trustabl/trustabl-action@v0` (the moving major tag). ## Notes diff --git a/action.yml b/action.yml index c2a185e..dcd2a8b 100644 --- a/action.yml +++ b/action.yml @@ -6,7 +6,7 @@ description: >- author: trustabl branding: icon: shield - color: blue + color: gray-dark inputs: # ── what to scan ──────────────────────────────────────────────────────── @@ -26,6 +26,14 @@ inputs: description: Pass --strict (fail on any finding regardless of severity). required: false default: "false" + vuln-scan: + description: >- + Pass --vuln-scan: match declared dependencies against a pinned OSV snapshot + and report known CVEs as findings (off by default). Each match becomes a + finding, so it flows through the readiness score, gating, annotations, and + SARIF. Fetches the OSV database on first use, then caches it. + required: false + default: "false" rules-ref: description: Pin trustabl-rules git ref. Empty = engine default. required: false diff --git a/assets/github_banner.jpg b/assets/github_banner.jpg new file mode 100644 index 0000000..54b404f Binary files /dev/null and b/assets/github_banner.jpg differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..4f3fb65 Binary files /dev/null and b/assets/logo.png differ diff --git a/capabilities.md b/capabilities.md index a619ee1..796c795 100644 --- a/capabilities.md +++ b/capabilities.md @@ -3,6 +3,10 @@ - **Static reliability/safety scan** for agent-SDK repos (Claude Agent SDK, OpenAI Agents SDK, Google ADK, MCP) — runs the upstream `trustabl` binary over your checkout, no daemon or hosted service. +- **Optional dependency CVE scan** (`vuln-scan: true`) — matches declared + dependencies against a pinned OSV snapshot and reports known CVEs as findings, + so they ride every surface (score, gate, annotations, Security tab) alongside a + dependencies-scanned / known-vulnerabilities headline. - **node20 TypeScript action, cross-platform** — `ubuntu-*`, `macos-*`, `windows-*` on x64/arm64; the binary is tool-cached so reruns are fast, and is **sha256-verified** against the release `checksums.txt` before it runs. diff --git a/dist/index.js b/dist/index.js index d5f6beb..5fffab0 100644 --- a/dist/index.js +++ b/dist/index.js @@ -85085,6 +85085,7 @@ function readInputs() { version: core.getInput('version') || 'latest', detectors: core.getInput('detectors'), strict: core.getBooleanInput('strict'), + vulnScan: core.getBooleanInput('vuln-scan'), rulesRef: core.getInput('rules-ref'), rulesRepo: core.getInput('rules-repo'), uploadSarif: core.getBooleanInput('upload-sarif'), @@ -85164,11 +85165,12 @@ const RELEASE_OWNER = 'trustabl'; const RELEASE_REPO = 'trustabl'; const RELEASE_BASE = 'https://github.com/trustabl/trustabl/releases/download'; // MIN_ENGINE_VERSION is the engine release that ships --json-out/--sarif-out, the -// Code-Scanning-valid SARIF (no fixes[]), and projected_scores. Older binaries -// still work via the two-scan fallback (single-scan + headroom ladder disabled); -// we only emit a soft upgrade warning, never a hard failure. -// TODO(owner): set to the engine release tag cut with those changes. -exports.MIN_ENGINE_VERSION = '0.0.0'; +// Code-Scanning-valid SARIF (no fixes[]), and projected_scores — all introduced +// together in v0.1.3. Older binaries still work via the two-scan fallback +// (single-scan + headroom ladder disabled); we only emit a soft upgrade warning, +// never a hard failure. (The v0.1.4 finding line-range shape is handled +// version-agnostically in types.ts, so it does not raise this floor.) +exports.MIN_ENGINE_VERSION = 'v0.1.3'; function binName() { return process.platform === 'win32' ? 'trustabl.exe' : 'trustabl'; } @@ -85320,6 +85322,12 @@ async function run() { const maxSev = (0, score_1.maxSeverity)(result.findings); const counts = (0, score_1.severityCounts)(result.findings); const projected = result.projected_scores ? (0, score_1.projectedReadiness)(result.projected_scores) : undefined; + // Dependency headline — only when the caller opted into --vuln-scan. The vuln + // matches themselves already flow through findings (counts, gate, annotations, + // SARIF); this is the BOM/OSV summary on top. + const deps = inputs.vulnScan + ? { scanned: result.dependencies?.length ?? 0, vulnerable: result.vulnerabilities?.length ?? 0 } + : undefined; const gate = (0, gate_1.evaluateGate)({ nativeExit, risk: riskScore, @@ -85338,6 +85346,7 @@ async function run() { nativeExit, severityCounts: counts, projected, + deps, gate, rulesVersion: result.rules_version, }; @@ -85487,10 +85496,14 @@ function emitAnnotations(findings, max) { const sorted = [...findings].sort((a, b) => (types_1.SEVERITY_RANK[b.severity] ?? -1) - (types_1.SEVERITY_RANK[a.severity] ?? -1)); const shown = max > 0 ? sorted.slice(0, max) : sorted; for (const f of shown) { + const { start, end } = (0, types_1.findingLines)(f); const props = { title: `${f.rule_id}: ${f.title}`, file: f.file_path || undefined, - startLine: f.line > 0 ? f.line : undefined, + startLine: start > 0 ? start : undefined, + // Span a range only for a genuine multi-line finding with a valid start; a + // single-line finding sets startLine alone (GitHub renders one line). + endLine: start > 0 && end > start ? end : undefined, }; const msg = f.explanation || f.title || f.rule_id; if (f.severity === 'critical' || f.severity === 'high') @@ -85598,6 +85611,10 @@ function buildConsoleLines(d) { L.push(row(`Fix +low ${p.fixMedium} -> ${p.fixLow} (+${p.fixLow - p.fixMedium})`)); L.push(row(`Fix +info ${p.fixLow} -> ${p.fixAll} (+${p.fixAll - p.fixLow})`)); } + if (d.deps) { + L.push(rule()); + L.push(row(`Dependencies ${d.deps.scanned} scanned, ${d.deps.vulnerable} known vulns`)); + } L.push(rule()); L.push(row(`Max severity: ${d.maxSeverity} Native exit: ${d.nativeExit}`)); L.push(rule()); @@ -85719,6 +85736,10 @@ function buildSummaryMarkdown(d) { L.push(`| Readiness score | \`${d.readiness}\` |`); L.push(`| Risk score | \`${d.risk}\` |`); L.push(`| Findings | \`${d.findingsCount}\` |`); + if (d.deps) { + L.push(`| Dependencies scanned | \`${d.deps.scanned}\` |`); + L.push(`| Known vulnerabilities | \`${d.deps.vulnerable}\` |`); + } L.push(`| Max severity | \`${d.maxSeverity}\` |`); L.push(`| Native exit | \`${d.nativeExit}\` |`); if (d.rulesVersion) @@ -85796,6 +85817,8 @@ function baseArgs(inputs) { args.push('--detectors', inputs.detectors); if (inputs.strict) args.push('--strict'); + if (inputs.vulnScan) + args.push('--vuln-scan'); if (inputs.rulesRef) args.push('--rules-ref', inputs.rulesRef); if (inputs.rulesRepo) @@ -86185,17 +86208,30 @@ async function uploadSarif(token, ctx, sarifPath) { "use strict"; // Typed view of the engine's JSON ScanResult. Ported from trustabl-vscode -// (src/types.ts) and extended with projected_scores (engine >= the release that -// added analysis.Project; optional so older binaries parse cleanly). +// (src/types.ts) and extended with projected_scores (engine >= v0.1.3) and the +// dependency BOM / OSV vulnerabilities (engine >= v0.1.4). All additive fields +// are optional so older binaries parse cleanly. Object.defineProperty(exports, "__esModule", ({ value: true })); exports.SEVERITY_RANK = void 0; +exports.findingLines = findingLines; exports.parseScanResult = parseScanResult; exports.SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4, }; +// findingLines resolves a finding's 1-indexed inclusive line range across engine +// versions: start_line/end_line (engine >= v0.1.4) with a fallback to the legacy +// single `line`. `start` is 0 for repo-scope findings with no source location, so +// callers must treat 0 as "no line". `end` is never less than `start`. +function findingLines(f) { + const start = f.start_line ?? f.line ?? 0; + const end = Math.max(start, f.end_line ?? start); + return { start, end }; +} // Tolerant parse: read only the fields we use, ignore the rest, so a future // engine that adds fields will not break the action. projected_scores is carried -// through only when the engine emitted a complete object (all five tiers numeric). +// through only when the engine emitted a complete object (all five tiers numeric); +// dependencies/vulnerabilities stay undefined (not []) when the engine omits them, +// so a missing array is distinguishable from an empty one. function parseScanResult(stdout) { const data = JSON.parse(stdout); if (data === null || typeof data !== 'object') { @@ -86210,6 +86246,8 @@ function parseScanResult(stdout) { surfaces: Array.isArray(data.surfaces) ? data.surfaces : [], overall_score: data.overall_score ?? 0, projected_scores: validProjected(data.projected_scores), + dependencies: Array.isArray(data.dependencies) ? data.dependencies : undefined, + vulnerabilities: Array.isArray(data.vulnerabilities) ? data.vulnerabilities : undefined, coverage: data.coverage ?? { files_parsed: 0, files_skipped: 0 }, rules_version: data.rules_version ?? '', rules_from_cache: data.rules_from_cache ?? false, diff --git a/package-lock.json b/package-lock.json index 274a4d0..4cda1bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "trustabl-action", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trustabl-action", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { "@actions/artifact": "^2.1.11", diff --git a/package.json b/package.json index 983b748..052f366 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trustabl-action", - "version": "0.2.0", + "version": "0.3.0", "private": true, "description": "Static reliability/safety scanner for AI agent repos (Claude, OpenAI, Google ADK, MCP) — GitHub Action.", "main": "dist/index.js", diff --git a/src/inputs.ts b/src/inputs.ts index 0023672..bf6f232 100644 --- a/src/inputs.ts +++ b/src/inputs.ts @@ -10,6 +10,7 @@ export interface Inputs { version: string; detectors: string; strict: boolean; + vulnScan: boolean; rulesRef: string; rulesRepo: string; uploadSarif: boolean; @@ -64,6 +65,7 @@ export function readInputs(): Inputs { version: core.getInput('version') || 'latest', detectors: core.getInput('detectors'), strict: core.getBooleanInput('strict'), + vulnScan: core.getBooleanInput('vuln-scan'), rulesRef: core.getInput('rules-ref'), rulesRepo: core.getInput('rules-repo'), uploadSarif: core.getBooleanInput('upload-sarif'), diff --git a/src/install.ts b/src/install.ts index c06cf97..c1df033 100644 --- a/src/install.ts +++ b/src/install.ts @@ -17,11 +17,12 @@ const RELEASE_REPO = 'trustabl'; const RELEASE_BASE = 'https://github.com/trustabl/trustabl/releases/download'; // MIN_ENGINE_VERSION is the engine release that ships --json-out/--sarif-out, the -// Code-Scanning-valid SARIF (no fixes[]), and projected_scores. Older binaries -// still work via the two-scan fallback (single-scan + headroom ladder disabled); -// we only emit a soft upgrade warning, never a hard failure. -// TODO(owner): set to the engine release tag cut with those changes. -export const MIN_ENGINE_VERSION = '0.0.0'; +// Code-Scanning-valid SARIF (no fixes[]), and projected_scores — all introduced +// together in v0.1.3. Older binaries still work via the two-scan fallback +// (single-scan + headroom ladder disabled); we only emit a soft upgrade warning, +// never a hard failure. (The v0.1.4 finding line-range shape is handled +// version-agnostically in types.ts, so it does not raise this floor.) +export const MIN_ENGINE_VERSION = 'v0.1.3'; export interface Capabilities { fileOut: boolean; // engine supports --json-out / --sarif-out (single-scan dual output) diff --git a/src/main.ts b/src/main.ts index 2e515e3..2a24076 100644 --- a/src/main.ts +++ b/src/main.ts @@ -46,6 +46,12 @@ async function run(): Promise { const maxSev = maxSeverity(result.findings); const counts = severityCounts(result.findings); const projected = result.projected_scores ? projectedReadiness(result.projected_scores) : undefined; + // Dependency headline — only when the caller opted into --vuln-scan. The vuln + // matches themselves already flow through findings (counts, gate, annotations, + // SARIF); this is the BOM/OSV summary on top. + const deps = inputs.vulnScan + ? { scanned: result.dependencies?.length ?? 0, vulnerable: result.vulnerabilities?.length ?? 0 } + : undefined; const gate = evaluateGate({ nativeExit, @@ -66,6 +72,7 @@ async function run(): Promise { nativeExit, severityCounts: counts, projected, + deps, gate, rulesVersion: result.rules_version, }; diff --git a/src/report/annotations.ts b/src/report/annotations.ts index 01bb8c2..cc9d2a9 100644 --- a/src/report/annotations.ts +++ b/src/report/annotations.ts @@ -2,7 +2,7 @@ // changed lines in the diff; on push they render on the run. Also the fallback // channel when SARIF upload is unavailable. Capped and sorted worst-first. import * as core from '@actions/core'; -import { Finding, SEVERITY_RANK } from '../types'; +import { Finding, SEVERITY_RANK, findingLines } from '../types'; export function emitAnnotations(findings: Finding[], max: number): void { const sorted = [...findings].sort( @@ -11,10 +11,14 @@ export function emitAnnotations(findings: Finding[], max: number): void { const shown = max > 0 ? sorted.slice(0, max) : sorted; for (const f of shown) { + const { start, end } = findingLines(f); const props: core.AnnotationProperties = { title: `${f.rule_id}: ${f.title}`, file: f.file_path || undefined, - startLine: f.line > 0 ? f.line : undefined, + startLine: start > 0 ? start : undefined, + // Span a range only for a genuine multi-line finding with a valid start; a + // single-line finding sets startLine alone (GitHub renders one line). + endLine: start > 0 && end > start ? end : undefined, }; const msg = f.explanation || f.title || f.rule_id; if (f.severity === 'critical' || f.severity === 'high') core.error(msg, props); diff --git a/src/report/console.test.ts b/src/report/console.test.ts index 210c06c..c2a49aa 100644 --- a/src/report/console.test.ts +++ b/src/report/console.test.ts @@ -34,4 +34,15 @@ describe('buildConsoleLines', () => { const lines = buildConsoleLines({ ...base, projected: undefined }); expect(lines.some((l) => l.includes('Fix critical'))).toBe(false); }); + + it('renders the dependency line only when deps is present (--vuln-scan)', () => { + expect(buildConsoleLines(base).some((l) => l.includes('Dependencies'))).toBe(false); + const lines = buildConsoleLines({ ...base, deps: { scanned: 12, vulnerable: 2 } }); + expect(lines.some((l) => l.includes('Dependencies 12 scanned, 2 known vulns'))).toBe(true); + // The added row must preserve the box-width invariant. + const width = lines.find((l) => l.startsWith('+'))!.length; + for (const l of lines) { + if (l.startsWith('|') || l.startsWith('+')) expect(l.length).toBe(width); + } + }); }); diff --git a/src/report/console.ts b/src/report/console.ts index 6c57c2c..bf465ac 100644 --- a/src/report/console.ts +++ b/src/report/console.ts @@ -51,6 +51,10 @@ export function buildConsoleLines(d: ReportData): string[] { L.push(row(`Fix +low ${p.fixMedium} -> ${p.fixLow} (+${p.fixLow - p.fixMedium})`)); L.push(row(`Fix +info ${p.fixLow} -> ${p.fixAll} (+${p.fixAll - p.fixLow})`)); } + if (d.deps) { + L.push(rule()); + L.push(row(`Dependencies ${d.deps.scanned} scanned, ${d.deps.vulnerable} known vulns`)); + } L.push(rule()); L.push(row(`Max severity: ${d.maxSeverity} Native exit: ${d.nativeExit}`)); L.push(rule()); diff --git a/src/report/model.ts b/src/report/model.ts index 10dc9e8..17b923b 100644 --- a/src/report/model.ts +++ b/src/report/model.ts @@ -4,6 +4,15 @@ import { Severity } from '../types'; import { MaxSeverity, ProjectedReadiness } from '../score'; import { GateResult } from '../gate'; +// DepsSummary is the dependency-scan headline shown only when --vuln-scan ran. +// `vulnerable` counts OSV matches (one per advisory × affected dependency); those +// matches also appear as findings, so they are already reflected in the severity +// counts and gate. +export interface DepsSummary { + scanned: number; + vulnerable: number; +} + export interface ReportData { repoLabel: string; branch: string; @@ -14,6 +23,7 @@ export interface ReportData { nativeExit: number; severityCounts: Record; projected?: ProjectedReadiness; // absent when the engine predates projected_scores + deps?: DepsSummary; // present only when --vuln-scan ran gate: GateResult; rulesVersion: string; } diff --git a/src/report/summary.test.ts b/src/report/summary.test.ts index f09a24b..e74653e 100644 --- a/src/report/summary.test.ts +++ b/src/report/summary.test.ts @@ -43,4 +43,11 @@ describe('buildSummaryMarkdown', () => { expect(md).not.toContain('Projected headroom'); expect(md).toContain('### ✅ Passed scanning'); }); + + it('shows dependency counts in the metrics table only when deps is present (--vuln-scan)', () => { + expect(buildSummaryMarkdown(data)).not.toContain('Dependencies scanned'); + const md = buildSummaryMarkdown({ ...data, deps: { scanned: 12, vulnerable: 2 } }); + expect(md).toContain('| Dependencies scanned | `12` |'); + expect(md).toContain('| Known vulnerabilities | `2` |'); + }); }); diff --git a/src/report/summary.ts b/src/report/summary.ts index c78b3b3..9054647 100644 --- a/src/report/summary.ts +++ b/src/report/summary.ts @@ -74,6 +74,10 @@ export function buildSummaryMarkdown(d: ReportData): string { L.push(`| Readiness score | \`${d.readiness}\` |`); L.push(`| Risk score | \`${d.risk}\` |`); L.push(`| Findings | \`${d.findingsCount}\` |`); + if (d.deps) { + L.push(`| Dependencies scanned | \`${d.deps.scanned}\` |`); + L.push(`| Known vulnerabilities | \`${d.deps.vulnerable}\` |`); + } L.push(`| Max severity | \`${d.maxSeverity}\` |`); L.push(`| Native exit | \`${d.nativeExit}\` |`); if (d.rulesVersion) L.push(`| Rules version | \`${d.rulesVersion}\` |`); diff --git a/src/runner.ts b/src/runner.ts index 5995120..59d6054 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -15,6 +15,7 @@ function baseArgs(inputs: Inputs): string[] { const args = ['scan', inputs.target || '.', '--no-progress']; if (inputs.detectors) args.push('--detectors', inputs.detectors); if (inputs.strict) args.push('--strict'); + if (inputs.vulnScan) args.push('--vuln-scan'); if (inputs.rulesRef) args.push('--rules-ref', inputs.rulesRef); if (inputs.rulesRepo) args.push('--rules-repo', inputs.rulesRepo); return args; diff --git a/src/types.test.ts b/src/types.test.ts index 1a0babe..9bf8eee 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -1,10 +1,15 @@ -import { parseScanResult } from './types'; +import { parseScanResult, findingLines, Finding } from './types'; const minimal = JSON.stringify({ scan_id: 's', repo: 'o/r', findings: [], surfaces: [], overall_score: 1, coverage: { files_parsed: 1, files_skipped: 0 }, rules_version: 'abc', rules_from_cache: false, }); +const mkFinding = (over: Partial): Finding => ({ + rule_id: 'R', category: '', scope: 'tool', severity: 'high', tool_name: '', + file_path: 'f.py', title: 't', explanation: '', suggested_fix: '', confidence: 1, ...over, +}); + describe('parseScanResult', () => { it('parses a minimal result', () => { const r = parseScanResult(minimal); @@ -45,4 +50,56 @@ describe('parseScanResult', () => { const extra = JSON.stringify({ ...JSON.parse(minimal), brand_new_field: 42 }); expect(parseScanResult(extra).scan_id).toBe('s'); }); + + it('carries dependencies + vulnerabilities through when present (engine >= v0.1.4 / --vuln-scan)', () => { + const withDeps = JSON.stringify({ + ...JSON.parse(minimal), + dependencies: [{ name: 'requests', version: '2.0.0', ecosystem: 'PyPI', source: 'req.txt', start_line: 3, end_line: 3 }], + vulnerabilities: [{ dep: { name: 'requests', ecosystem: 'PyPI', source: 'req.txt', start_line: 3, end_line: 3 }, id: 'GHSA-x', severity: 'high' }], + }); + const r = parseScanResult(withDeps); + expect(r.dependencies).toHaveLength(1); + expect(r.dependencies?.[0].name).toBe('requests'); + expect(r.vulnerabilities?.[0].id).toBe('GHSA-x'); + }); + + it('leaves dependencies/vulnerabilities undefined (not []) when the engine omits them', () => { + const r = parseScanResult(minimal); + expect(r.dependencies).toBeUndefined(); + expect(r.vulnerabilities).toBeUndefined(); + }); + + it('ignores non-array dependencies/vulnerabilities (defensive)', () => { + const bad = JSON.stringify({ ...JSON.parse(minimal), dependencies: {}, vulnerabilities: 7 }); + const r = parseScanResult(bad); + expect(r.dependencies).toBeUndefined(); + expect(r.vulnerabilities).toBeUndefined(); + }); +}); + +describe('findingLines', () => { + it('reads the new start_line/end_line range (engine >= v0.1.4)', () => { + expect(findingLines(mkFinding({ start_line: 5, end_line: 8 }))).toEqual({ start: 5, end: 8 }); + }); + + it('treats a single-line entity as start == end', () => { + expect(findingLines(mkFinding({ start_line: 12, end_line: 12 }))).toEqual({ start: 12, end: 12 }); + }); + + it('falls back to the legacy single `line` (engine < v0.1.4)', () => { + expect(findingLines(mkFinding({ line: 42 }))).toEqual({ start: 42, end: 42 }); + }); + + it('prefers start_line over a stray legacy line if both are present', () => { + expect(findingLines(mkFinding({ start_line: 5, end_line: 6, line: 99 }))).toEqual({ start: 5, end: 6 }); + }); + + it('returns {0, 0} for a repo-scope finding with no source location', () => { + expect(findingLines(mkFinding({ scope: 'repo', start_line: 0, end_line: 0 }))).toEqual({ start: 0, end: 0 }); + expect(findingLines(mkFinding({}))).toEqual({ start: 0, end: 0 }); + }); + + it('never lets end fall below start (clamps a malformed range)', () => { + expect(findingLines(mkFinding({ start_line: 10, end_line: 4 }))).toEqual({ start: 10, end: 10 }); + }); }); diff --git a/src/types.ts b/src/types.ts index ce749fe..cb0d4c7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,10 @@ // Typed view of the engine's JSON ScanResult. Ported from trustabl-vscode -// (src/types.ts) and extended with projected_scores (engine >= the release that -// added analysis.Project; optional so older binaries parse cleanly). +// (src/types.ts) and extended with projected_scores (engine >= v0.1.3) and the +// dependency BOM / OSV vulnerabilities (engine >= v0.1.4). All additive fields +// are optional so older binaries parse cleanly. export type Severity = 'info' | 'low' | 'medium' | 'high' | 'critical'; -export type Scope = 'tool' | 'agent' | 'subagent' | 'repo' | ''; +export type Scope = 'tool' | 'agent' | 'subagent' | 'skill' | 'repo' | ''; export const SEVERITY_RANK: Record = { info: 0, low: 1, medium: 2, high: 3, critical: 4, @@ -16,15 +17,32 @@ export interface Finding { severity: Severity; tool_name: string; file_path: string; - line: number; + // Inclusive 1-indexed line range of the entity the finding fired on. Engine + // >= v0.1.4 emits start_line/end_line (end_line == start_line for a single-line + // entity; both 0 for repo-scope findings with no source location). Older engines + // emitted a single `line`, kept here as a read-fallback. Resolve via + // findingLines() — do not read these fields directly. + start_line?: number; + end_line?: number; + line?: number; // legacy (engine < v0.1.4); fallback for start_line only. title: string; explanation: string; suggested_fix: string; confidence: number; } +// findingLines resolves a finding's 1-indexed inclusive line range across engine +// versions: start_line/end_line (engine >= v0.1.4) with a fallback to the legacy +// single `line`. `start` is 0 for repo-scope findings with no source location, so +// callers must treat 0 as "no line". `end` is never less than `start`. +export function findingLines(f: Finding): { start: number; end: number } { + const start = f.start_line ?? f.line ?? 0; + const end = Math.max(start, f.end_line ?? start); + return { start, end }; +} + export interface SurfaceReadiness { - kind: 'tool' | 'agent' | 'subagent' | 'repo'; + kind: 'tool' | 'agent' | 'subagent' | 'skill' | 'repo'; name: string; file_path: string; score: number; @@ -43,6 +61,31 @@ export interface ProjectedScores { fix_all: number; } +// DepRef mirrors models.DepRef: one declared dependency in the repo-wide BOM the +// engine emits (engine >= v0.1.4). start_line/end_line point at the declaration +// line in `source` (the manifest file). +export interface DepRef { + name: string; + version?: string; + ecosystem: string; + source: string; + start_line: number; + end_line: number; +} + +// DepVuln mirrors models.DepVuln: one OSV match against a declared dependency, +// present only when the scan ran with --vuln-scan. Each match is also synthesized +// into a Finding by the engine (so it flows through scoring, gating, annotations, +// and SARIF) — this array is the structured companion. +export interface DepVuln { + dep: DepRef; + id: string; + aliases?: string[]; + summary?: string; + severity: Severity; + fixed_in?: string; +} + export interface Coverage { files_parsed: number; files_skipped: number; @@ -56,6 +99,11 @@ export interface ScanResult { surfaces: SurfaceReadiness[]; overall_score: number; projected_scores?: ProjectedScores; + // dependencies is the declared-dependency BOM (engine >= v0.1.4); absent on + // older binaries. vulnerabilities holds OSV matches and is present only when the + // scan ran with --vuln-scan. + dependencies?: DepRef[]; + vulnerabilities?: DepVuln[]; coverage: Coverage; rules_version: string; rules_from_cache: boolean; @@ -63,7 +111,9 @@ export interface ScanResult { // Tolerant parse: read only the fields we use, ignore the rest, so a future // engine that adds fields will not break the action. projected_scores is carried -// through only when the engine emitted a complete object (all five tiers numeric). +// through only when the engine emitted a complete object (all five tiers numeric); +// dependencies/vulnerabilities stay undefined (not []) when the engine omits them, +// so a missing array is distinguishable from an empty one. export function parseScanResult(stdout: string): ScanResult { const data = JSON.parse(stdout) as Partial | null; if (data === null || typeof data !== 'object') { @@ -78,6 +128,8 @@ export function parseScanResult(stdout: string): ScanResult { surfaces: Array.isArray(data.surfaces) ? data.surfaces : [], overall_score: data.overall_score ?? 0, projected_scores: validProjected(data.projected_scores), + dependencies: Array.isArray(data.dependencies) ? data.dependencies : undefined, + vulnerabilities: Array.isArray(data.vulnerabilities) ? data.vulnerabilities : undefined, coverage: data.coverage ?? { files_parsed: 0, files_skipped: 0 }, rules_version: data.rules_version ?? '', rules_from_cache: data.rules_from_cache ?? false,