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 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,