diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml
index 67c51ae..3a47dcb 100644
--- a/.github/workflows/build-check.yml
+++ b/.github/workflows/build-check.yml
@@ -15,13 +15,13 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
- node-version: 20
+ node-version: 24
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm test
- run: npm run build
- # A node20 action runs dist/index.js straight from the consumer's checkout,
+ # A node24 action runs dist/index.js straight from the consumer's checkout,
# so a stale committed bundle ships stale code. Fail if the freshly built
# dist/ differs from what's committed.
- name: Verify dist/ is up to date
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..04a7993 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,59 @@ 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.1] — 2026-06-09
+
+### Changed
+
+- **Runtime is now Node.js 24** (`runs.using: node24`). GitHub is deprecating the
+ Node 20 Actions runtime (runners default to Node 24 on 2026-06-16; Node 20 is
+ removed on 2026-09-16), so this moves ahead of the removal. No behavior change —
+ the bundled `dist/` is identical; the build CI and the `engines` field bump to
+ Node 24 to match the runtime.
+
+### Docs
+
+- README + capabilities now document the full SDK coverage (LangChain, CrewAI,
+ Pydantic AI, Vercel AI, AutoGen, MCP servers, and Claude subagents & skills),
+ the opt-in dependency CVE scan, and the complete `detectors` token list; install
+ pins bumped to `v0.3.1`.
+
+
+## [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..d3de82a 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,13 @@
+
+
+
+
# Trustabl Action
A GitHub Action that runs [trustabl](https://github.com/trustabl/trustabl) — the
-static reliability/safety analyzer for agent-SDK repos (Claude Agent SDK, OpenAI
-Agents SDK, Google ADK, MCP) — and surfaces the results where you work:
+static reliability/safety analyzer for agent repos (Claude Agent SDK, OpenAI
+Agents SDK, Google ADK, LangChain, CrewAI, Pydantic AI, Vercel AI, AutoGen, MCP
+servers, and Claude subagents & skills) — and surfaces the results where you work:
- **Inline PR annotations + the Security tab.** Findings are uploaded to GitHub
Code Scanning, so they appear on the changed lines in the PR diff and in the
@@ -12,6 +17,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 +78,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 +90,7 @@ jobs:
## Pinned + gated
```yaml
-- uses: trustabl/trustabl-action@v0.2.0
+- uses: trustabl/trustabl-action@v0.3.1
with:
version: v0.5.0
detectors: claude_sdk,openai_sdk
@@ -90,14 +99,62 @@ jobs:
artifact-retention-days: "30"
```
+## Enrich + auto-enrich
+
+When `enrich: true`, after the scan the action calls `trustabl enrich` with your
+LLM API key to generate AI explanations and code fixes for each finding.
+With `auto-enrich: true`, high-confidence fixes are applied directly to source
+files. With `create-fix-pr: true`, the patches are committed on a new branch
+and a pull request is opened for human review.
+
+```yaml
+permissions:
+ contents: write # push fix branch
+ pull-requests: write # open fix PR
+ security-events: write
+
+jobs:
+ scan:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: trustabl/trustabl-action@v0
+ with:
+ enrich: true
+ llm-key: ${{ secrets.ANTHROPIC_API_KEY }}
+ auto-enrich: true
+ create-fix-pr: true
+```
+
+Enrich is best-effort — if it fails the scan result and gate decision are
+unaffected and a warning is emitted instead of failing the job.
+
+**PR-only auto-enrich.** To generate explanations on every push but only apply
+fixes and open a fix PR on pull requests:
+
+```yaml
+- uses: trustabl/trustabl-action@v0
+ with:
+ enrich: true
+ llm-key: ${{ secrets.ANTHROPIC_API_KEY }}
+ auto-enrich: ${{ github.event_name == 'pull_request' }}
+ create-fix-pr: ${{ github.event_name == 'pull_request' }}
+```
+
+
+> **Required repo settings when using `create-fix-pr: true`:**
+> Go to **Settings → Actions → General → Workflow permissions** and enable
+> **Read and write permissions** + **Allow GitHub Actions to create and approve pull requests**.
+
## Inputs
| Name | Default | Description |
|---|---|---|
| `target` | `.` | Path or GitHub URL to scan. |
| `version` | `latest` | trustabl release tag (e.g. `v0.5.0`) or `latest`. |
-| `detectors` | _(all)_ | Comma-separated subset: `claude_sdk,openai_sdk,google_adk,openshell`. |
+| `detectors` | _(all)_ | Comma-separated SDK subset: `claude_sdk`, `openai_sdk`, `google_adk`, `openshell`, `mcp`, `langchain`, `crewai`, `pydantic_ai`, `vercel_ai`, `autogen`. |
| `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`. |
@@ -113,6 +170,14 @@ jobs:
| `severity-threshold` | `none` | Fail when any finding `>= severity` (`none`/`low`/`medium`/`high`/`critical`). |
| `branch` | _(auto)_ | Report branch label; auto-detected from the checkout. |
| `github-token` | `${{ github.token }}` | Token for release lookup, SARIF upload, and PR comments. |
+| `enrich` | `false` | Run AI enrichment on findings (explanations + fixes). Requires `llm-key`. |
+| `llm-provider` | `anthropic` | LLM provider for enrichment (e.g. `anthropic`). |
+| `llm-key` | _(none)_ | API key for the LLM provider (BYOK). Required when `enrich` is true. |
+| `auto-enrich` | `false` | Apply AI-generated fixes to source files. Requires `enrich: true`. |
+| `create-fix-pr` | `false` | Open a PR with applied fixes. Requires `auto-enrich: true`. Needs `contents: write` + `pull-requests: write`. |
+| `enrich-model` | _(binary default)_ | Claude model for enrichment (e.g. `claude-sonnet-4-6`). Defaults to `claude-haiku-4-5`. |
+| `enrich-rules` | _(all)_ | Comma-separated rule IDs to enrich (e.g. `ADK-201,ADK-105`). Empty = all findings. |
+| `fix-pr-base` | _(current branch)_ | Base branch for the fix PR. |
## Outputs
@@ -127,6 +192,8 @@ jobs:
| `sarif-file` | Path to the emitted SARIF file. |
| `json-file` | Path to the emitted JSON file. |
| `artifact-name` | Artifact name used for the upload. |
+| `enrich-json-file` | Path to `enriched.json` (when `enrich` is true). |
+| `fix-pr-url` | URL of the opened fix PR (when `create-fix-pr` is true). |
## How it works
@@ -137,6 +204,11 @@ jobs:
one analysis pass produces both artifacts. Older engines fall back to two scans
automatically (and the headroom ladder is hidden, since it needs the engine's
`projected_scores`). Use `version: latest` to get the fast path.
+- **Dependency CVE scan (opt-in).** With `vuln-scan: true`, declared dependencies
+ are matched against a pinned OSV snapshot; each known CVE becomes a finding (so
+ it counts toward the score, gate, annotations, and Security tab), plus a
+ dependencies-scanned / known-vulnerabilities line in every report. The OSV
+ database is fetched once on first use, then cached.
- **Honest gating.** A failed or empty scan errors the job rather than reporting a
clean score. The gate decision is exit-code/threshold-based, surfaced in the
Step Summary and the PR comment.
@@ -156,7 +228,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.1`.
- Or track the line: `uses: trustabl/trustabl-action@v0` (the moving major tag).
## Notes
@@ -169,7 +241,7 @@ After a run, open the run page and find the **`trustabl-scan-results`** artifact
## Development
-This is a node20 TypeScript action bundled to `dist/` with
+This is a node24 TypeScript action bundled to `dist/` with
[`ncc`](https://github.com/vercel/ncc).
```bash
@@ -180,7 +252,7 @@ npm run build # bundle to dist/index.js (commit the result)
npm run all # all of the above
```
-`dist/` is committed because a node20 action runs `dist/index.js` directly from
+`dist/` is committed because a node24 action runs `dist/index.js` directly from
the consumer's checkout of the release tag. The **Build check** workflow fails a
PR whose `dist/` is stale, so always `npm run build` and commit after changing
`src/`.
diff --git a/action.yml b/action.yml
index c2a185e..6928339 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 ────────────────────────────────────────────────────────
@@ -19,13 +19,21 @@ inputs:
required: false
default: latest
detectors:
- description: Comma-separated SDK detectors (claude_sdk,openai_sdk,google_adk,openshell). Empty = all.
+ description: Comma-separated SDK detectors (claude_sdk,openai_sdk,google_adk,openshell,mcp,langchain,crewai,pydantic_ai,vercel_ai,autogen). Empty = all.
required: false
default: ""
strict:
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
@@ -107,6 +115,41 @@ inputs:
required: false
default: ${{ github.token }}
+ # ── enrich ──────────────────────────────────────────────────────────────
+ enrich:
+ description: Run AI enrichment on findings (explanations + fixes). Requires llm-key.
+ required: false
+ default: "false"
+ llm-provider:
+ description: LLM provider for enrichment (e.g. anthropic). Defaults to anthropic.
+ required: false
+ default: "anthropic"
+ llm-key:
+ description: API key for the LLM provider (BYOK). Required when enrich is true.
+ required: false
+ default: ""
+ auto-enrich:
+ description: Apply AI-generated fixes to source files. Requires enrich true.
+ required: false
+ default: "false"
+ create-fix-pr:
+ description: Open a PR with applied fixes. Requires auto-enrich true.
+ required: false
+ default: "false"
+ enrich-model:
+ description: Claude model to use for enrichment (e.g. claude-sonnet-4-6). Defaults to the trustabl binary default (claude-haiku-4-5).
+ required: false
+ default: ""
+ enrich-rules:
+ description: Comma-separated rule IDs to enrich (e.g. ADK-201,ADK-105). Empty = all findings.
+ required: false
+ default: ""
+ fix-pr-base:
+ description: Base branch for the fix PR. Defaults to the branch being scanned.
+ required: false
+ default: ""
+
+
outputs:
exit-code:
description: trustabl native exit code (0 clean, 1 findings, 2 error).
@@ -126,7 +169,11 @@ outputs:
description: Name of the uploaded artifact (when upload-artifact is true).
sarif-uploaded:
description: Whether the SARIF was accepted by Code Scanning (true/false).
+ enrich-json-file:
+ description: Path to enriched.json (when enrich is true).
+ fix-pr-url:
+ description: URL of the opened fix PR (when create-fix-pr is true).
runs:
- using: node20
+ using: node24
main: dist/index.js
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..8887e14 100644
--- a/capabilities.md
+++ b/capabilities.md
@@ -1,9 +1,14 @@
### Trustabl Action — capabilities
-- **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.
-- **node20 TypeScript action, cross-platform** — `ubuntu-*`, `macos-*`,
+- **Static reliability/safety scan** for agent repos (Claude Agent SDK, OpenAI
+ Agents SDK, Google ADK, LangChain, CrewAI, Pydantic AI, Vercel AI, AutoGen, MCP
+ servers, and Claude subagents & skills) — 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.
+- **node24 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.
- **Inline PR annotations + GitHub Security tab** — findings are uploaded to Code
diff --git a/dist/index.js b/dist/index.js
index d5f6beb..cacd668 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -84832,8 +84832,10 @@ function getRunContext() {
// payload.pull_request is loosely typed (index signature); read defensively.
const pr = ctx.payload.pull_request;
let prHeadRef;
+ let prBaseRef;
if (isPullRequest && pr) {
prHeadRef = pr.head?.ref;
+ prBaseRef = pr.base?.ref;
}
return {
eventName: ctx.eventName,
@@ -84843,6 +84845,7 @@ function getRunContext() {
ref: ctx.ref,
prNumber: pr?.number,
prHeadRef,
+ prBaseRef,
isPullRequest,
};
}
@@ -85080,11 +85083,12 @@ function parsePositiveInt(raw, fallback, name) {
return parseInt(v, 10);
}
function readInputs() {
- return {
+ const inputs = {
target: core.getInput('target') || '.',
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'),
@@ -85100,7 +85104,25 @@ function readInputs() {
annotations: core.getBooleanInput('annotations'),
maxAnnotations: parsePositiveInt(core.getInput('max-annotations'), 10, 'max-annotations'),
githubToken: core.getInput('github-token'),
+ enrich: core.getBooleanInput('enrich'),
+ llmProvider: core.getInput('llm-provider') || 'anthropic',
+ llmKey: core.getInput('llm-key'),
+ autoEnrich: core.getBooleanInput('auto-enrich'),
+ createFixPr: core.getBooleanInput('create-fix-pr'),
+ fixPrBase: core.getInput('fix-pr-base'),
+ enrichModel: core.getInput('enrich-model'),
+ enrichRules: core.getInput('enrich-rules').split(',').map((r) => r.trim()).filter(Boolean),
};
+ if (inputs.enrich && !inputs.llmKey) {
+ throw new Error('llm-key is required when enrich is true');
+ }
+ if (inputs.autoEnrich && !inputs.enrich) {
+ throw new Error('auto-enrich requires enrich: true');
+ }
+ if (inputs.createFixPr && !inputs.autoEnrich) {
+ throw new Error('create-fix-pr requires auto-enrich: true');
+ }
+ return inputs;
}
@@ -85164,11 +85186,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';
}
@@ -85305,6 +85328,7 @@ const annotations_1 = __nccwpck_require__(856);
const sarif_1 = __nccwpck_require__(93777);
const artifact_1 = __nccwpck_require__(11372);
const comment_1 = __nccwpck_require__(4909);
+const enrich_1 = __nccwpck_require__(74347);
const SCAN_TIMEOUT_MS = 10 * 60 * 1000;
async function run() {
const inputs = (0, inputs_1.readInputs)();
@@ -85320,6 +85344,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,
@@ -85328,6 +85358,25 @@ async function run() {
riskThreshold: inputs.riskScoreThreshold,
severityThreshold: inputs.severityThreshold,
});
+ // A remote URL target scans a different repo than this checkout, so the
+ // caller-repo-scoped surfaces (Code Scanning upload, PR comment) would
+ // misattribute results to this repo — skip them.
+ const remoteTarget = (0, git_1.isRemoteTarget)(inputs.target);
+ // Surface 5: enrich → auto-enrich → fix PR (best-effort, never fails the job).
+ // Runs before summary/comment so the fix PR URL is included in both.
+ let fixPrUrl = null;
+ if (inputs.enrich && !remoteTarget) {
+ const er = await (0, enrich_1.runEnrich)(installed.binPath, inputs, ctx);
+ fixPrUrl = er.fixPrUrl;
+ core.setOutput('enrich-json-file', er.enrichedJsonFile);
+ core.setOutput('fix-pr-url', er.fixPrUrl ?? '');
+ if (er.fixPrUrl)
+ core.info(`Fix PR opened: ${er.fixPrUrl}`);
+ }
+ else {
+ core.setOutput('enrich-json-file', '');
+ core.setOutput('fix-pr-url', '');
+ }
const data = {
repoLabel: (0, git_1.repoLabel)(inputs.target, `${ctx.owner}/${ctx.repo}`),
branch: (0, git_1.resolveBranch)(inputs.branch, ctx.prHeadRef, ctx.ref),
@@ -85338,8 +85387,10 @@ async function run() {
nativeExit,
severityCounts: counts,
projected,
+ deps,
gate,
rulesVersion: result.rules_version,
+ fixPrUrl: fixPrUrl ?? undefined,
};
// Outputs (v1 names preserved) + one additive.
core.setOutput('exit-code', String(nativeExit));
@@ -85358,10 +85409,6 @@ async function run() {
if (inputs.annotations) {
(0, annotations_1.emitAnnotations)(result.findings, inputs.maxAnnotations);
}
- // A remote URL target scans a different repo than this checkout, so the
- // caller-repo-scoped surfaces (Code Scanning upload, PR comment) would
- // misattribute results to this repo — skip them.
- const remoteTarget = (0, git_1.isRemoteTarget)(inputs.target);
// Surface 1b: SARIF → Security tab (needs security-events: write).
if (inputs.uploadSarif && !remoteTarget) {
const res = await (0, sarif_1.uploadSarif)(inputs.githubToken, ctx, inputs.sarifFile);
@@ -85377,16 +85424,20 @@ async function run() {
}
core.setOutput('sarif-uploaded', 'false');
}
- // Downloadable artifact (JSON + SARIF).
- if (inputs.uploadArtifact) {
- const days = inputs.artifactRetentionDays ? parseInt(inputs.artifactRetentionDays, 10) : undefined;
- await (0, artifact_1.uploadResults)(inputs.artifactName, [inputs.jsonFile, inputs.sarifFile], days);
- }
// Surface 2: sticky PR comment (needs pull-requests: write; pull_request only;
// skipped for a remote target — the comment would describe a different repo).
if (inputs.commentOnPr && ctx.isPullRequest && !remoteTarget) {
await (0, comment_1.upsertComment)(inputs.githubToken, ctx, md);
}
+ // Downloadable artifact (JSON + SARIF + enriched.json when enrich is enabled).
+ // Runs after enrich so enriched.json is present if enrich ran.
+ if (inputs.uploadArtifact) {
+ const days = inputs.artifactRetentionDays ? parseInt(inputs.artifactRetentionDays, 10) : undefined;
+ const artifactFiles = [inputs.jsonFile, inputs.sarifFile];
+ if (inputs.enrich)
+ artifactFiles.push('enriched.json');
+ await (0, artifact_1.uploadResults)(inputs.artifactName, artifactFiles, days);
+ }
// Surface 3: status-check gating — the job status is the check.
if (gate.fail) {
core.setFailed(`Trustabl gate failed: ${gate.reasons.join('; ')}`);
@@ -85487,10 +85538,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 +85653,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 +85778,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)
@@ -85735,6 +85798,9 @@ function buildSummaryMarkdown(d) {
else {
L.push('### ✅ Passed scanning');
}
+ if (d.fixPrUrl) {
+ L.push('', `### 🔧 Fix PR`, '', `An auto-enrich fix PR was opened: [${d.fixPrUrl}](${d.fixPrUrl})`);
+ }
return L.join('\n');
}
async function writeStepSummary(md) {
@@ -85796,6 +85862,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)
@@ -86090,6 +86158,161 @@ async function upsertComment(token, ctx, body) {
}
+/***/ }),
+
+/***/ 74347:
+/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
+
+"use strict";
+
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() { return m[k]; } };
+ }
+ Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+ o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+ var ownKeys = function(o) {
+ ownKeys = Object.getOwnPropertyNames || function (o) {
+ var ar = [];
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+ return ar;
+ };
+ return ownKeys(o);
+ };
+ return function (mod) {
+ if (mod && mod.__esModule) return mod;
+ var result = {};
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+ __setModuleDefault(result, mod);
+ return result;
+ };
+})();
+Object.defineProperty(exports, "__esModule", ({ value: true }));
+exports.runEnrich = runEnrich;
+const core = __importStar(__nccwpck_require__(37484));
+const exec = __importStar(__nccwpck_require__(95236));
+const github = __importStar(__nccwpck_require__(93228));
+const ENRICHED_JSON = 'enriched.json';
+async function runEnrich(binPath, inputs, ctx) {
+ core.setSecret(inputs.llmKey);
+ try {
+ await exec.exec(binPath, ['llm', 'provider', 'set', inputs.llmProvider]);
+ await exec.exec(binPath, ['llm', 'key', 'set', inputs.llmKey], { silent: true });
+ }
+ catch (e) {
+ core.warning(`Enrich skipped: failed to configure LLM provider/key: ${e instanceof Error ? e.message : String(e)}`);
+ return { enrichedJsonFile: ENRICHED_JSON, fixPrUrl: null, appliedCount: 0 };
+ }
+ if (inputs.enrichModel) {
+ try {
+ await exec.exec(binPath, ['llm', 'model', 'set', inputs.enrichModel]);
+ }
+ catch (e) {
+ core.warning(`Enrich: failed to set model "${inputs.enrichModel}", using default: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ }
+ const args = [
+ 'enrich',
+ '--input', inputs.jsonFile,
+ '--repo', '.',
+ '--output', ENRICHED_JSON,
+ ];
+ if (inputs.autoEnrich)
+ args.push('--apply');
+ for (const rule of inputs.enrichRules)
+ args.push('--rule', rule);
+ try {
+ await exec.exec(binPath, args);
+ }
+ catch (e) {
+ core.warning(`Enrich failed: ${e instanceof Error ? e.message : String(e)}`);
+ return { enrichedJsonFile: ENRICHED_JSON, fixPrUrl: null, appliedCount: 0 };
+ }
+ if (!inputs.autoEnrich) {
+ return { enrichedJsonFile: ENRICHED_JSON, fixPrUrl: null, appliedCount: 0 };
+ }
+ const modified = await getModifiedFiles(inputs);
+ if (modified.length === 0) {
+ core.info('Enrich: no files modified by auto-enrich.');
+ return { enrichedJsonFile: ENRICHED_JSON, fixPrUrl: null, appliedCount: 0 };
+ }
+ core.info(`Enrich: ${modified.length} file(s) patched.`);
+ if (!inputs.createFixPr) {
+ return { enrichedJsonFile: ENRICHED_JSON, fixPrUrl: null, appliedCount: modified.length };
+ }
+ const fixPrUrl = await openFixPr(inputs, ctx, modified);
+ return { enrichedJsonFile: ENRICHED_JSON, fixPrUrl, appliedCount: modified.length };
+}
+const SCAN_OUTPUTS = new Set([ENRICHED_JSON, 'trustabl.json', 'trustabl.sarif']);
+async function getModifiedFiles(inputs) {
+ const { stdout } = await exec.getExecOutput('git', ['status', '--porcelain'], { silent: true });
+ const excluded = new Set([ENRICHED_JSON, inputs.jsonFile, inputs.sarifFile]);
+ return stdout
+ .split('\n')
+ .filter((l) => l.trim())
+ .map((l) => l.slice(3).trim())
+ .filter((f) => !excluded.has(f) && !SCAN_OUTPUTS.has(f) && !f.endsWith('.trustabl.bak'));
+}
+async function openFixPr(inputs, ctx, modified) {
+ const runId = github.context.runId;
+ const branch = `trustabl/fix-${runId}`;
+ const base = inputs.fixPrBase || ctx.prBaseRef || ctx.ref.replace('refs/heads/', '');
+ try {
+ await exec.exec('git', ['config', 'user.email', 'github-actions[bot]@users.noreply.github.com']);
+ await exec.exec('git', ['config', 'user.name', 'github-actions[bot]']);
+ await exec.exec('git', ['checkout', '-b', branch]);
+ await exec.exec('git', ['add', ...modified]);
+ await exec.exec('git', ['commit', '-m', `fix: Trustabl auto-enrich findings (run #${runId})`]);
+ await exec.exec('git', ['push', 'origin', branch]);
+ const octo = github.getOctokit(inputs.githubToken);
+ const { data: pr } = await octo.rest.pulls.create({
+ owner: ctx.owner,
+ repo: ctx.repo,
+ head: branch,
+ base,
+ title: `Trustabl auto-enrich — run #${runId}`,
+ body: buildFixPrBody(ctx, runId, modified),
+ });
+ return pr.html_url;
+ }
+ catch (e) {
+ const err = e;
+ if (err.status === 403) {
+ core.warning('Fix PR skipped: token lacks contents: write or pull-requests: write.');
+ }
+ else {
+ core.warning(`Fix PR failed: ${err.message ?? String(e)}`);
+ }
+ return null;
+ }
+}
+function buildFixPrBody(ctx, runId, modified) {
+ const runUrl = `https://github.com/${ctx.owner}/${ctx.repo}/actions/runs/${runId}`;
+ const fileList = modified.map((f) => `- \`${f}\``).join('\n');
+ return [
+ 'Automated fixes applied by [Trustabl](https://github.com/trustabl/trustabl-action).',
+ '',
+ `**Workflow run:** ${runUrl}`,
+ '',
+ `**Patched files (${modified.length}):**`,
+ fileList,
+ '',
+ '> Review each change before merging. False-positive fixes can be closed without merging.',
+ ].join('\n');
+}
+
+
/***/ }),
/***/ 93777:
@@ -86185,17 +86408,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 +86446,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..9168156 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "trustabl-action",
- "version": "0.2.0",
+ "version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trustabl-action",
- "version": "0.2.0",
+ "version": "0.3.1",
"license": "Apache-2.0",
"dependencies": {
"@actions/artifact": "^2.1.11",
diff --git a/package.json b/package.json
index 983b748..2553b48 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "trustabl-action",
- "version": "0.2.0",
+ "version": "0.3.1",
"private": true,
"description": "Static reliability/safety scanner for AI agent repos (Claude, OpenAI, Google ADK, MCP) — GitHub Action.",
"main": "dist/index.js",
@@ -12,7 +12,7 @@
},
"license": "Apache-2.0",
"engines": {
- "node": ">=20"
+ "node": ">=24"
},
"dependencies": {
"@actions/artifact": "^2.1.11",
diff --git a/src/context.ts b/src/context.ts
index a7b7fb8..ee2d492 100644
--- a/src/context.ts
+++ b/src/context.ts
@@ -14,6 +14,7 @@ export interface RunContext {
ref: string;
prNumber?: number;
prHeadRef?: string;
+ prBaseRef?: string;
isPullRequest: boolean;
}
@@ -22,11 +23,13 @@ export function getRunContext(): RunContext {
const isPullRequest =
ctx.eventName === 'pull_request' || ctx.eventName === 'pull_request_target';
// payload.pull_request is loosely typed (index signature); read defensively.
- const pr = ctx.payload.pull_request as { number?: number; head?: any } | undefined;
+ const pr = ctx.payload.pull_request as { number?: number; head?: any; base?: any } | undefined;
let prHeadRef: string | undefined;
+ let prBaseRef: string | undefined;
if (isPullRequest && pr) {
prHeadRef = pr.head?.ref;
+ prBaseRef = pr.base?.ref;
}
return {
@@ -37,6 +40,7 @@ export function getRunContext(): RunContext {
ref: ctx.ref,
prNumber: pr?.number,
prHeadRef,
+ prBaseRef,
isPullRequest,
};
}
diff --git a/src/inputs.ts b/src/inputs.ts
index 0023672..3d32ab4 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;
@@ -25,6 +26,14 @@ export interface Inputs {
annotations: boolean;
maxAnnotations: number;
githubToken: string;
+ enrich: boolean;
+ llmProvider: string;
+ llmKey: string;
+ autoEnrich: boolean;
+ createFixPr: boolean;
+ fixPrBase: string;
+ enrichModel: string;
+ enrichRules: string[];
}
export function parseSeverityThreshold(raw: string): SeverityThreshold {
@@ -59,11 +68,12 @@ export function parsePositiveInt(raw: string, fallback: number, name: string): n
}
export function readInputs(): Inputs {
- return {
+ const inputs: Inputs = {
target: core.getInput('target') || '.',
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'),
@@ -79,5 +89,26 @@ export function readInputs(): Inputs {
annotations: core.getBooleanInput('annotations'),
maxAnnotations: parsePositiveInt(core.getInput('max-annotations'), 10, 'max-annotations'),
githubToken: core.getInput('github-token'),
+ enrich: core.getBooleanInput('enrich'),
+ llmProvider: core.getInput('llm-provider') || 'anthropic',
+ llmKey: core.getInput('llm-key'),
+ autoEnrich: core.getBooleanInput('auto-enrich'),
+ createFixPr: core.getBooleanInput('create-fix-pr'),
+ fixPrBase: core.getInput('fix-pr-base'),
+ enrichModel: core.getInput('enrich-model'),
+ enrichRules: core.getInput('enrich-rules').split(',').map((r) => r.trim()).filter(Boolean),
+
};
+
+ if (inputs.enrich && !inputs.llmKey) {
+ throw new Error('llm-key is required when enrich is true');
+ }
+ if (inputs.autoEnrich && !inputs.enrich) {
+ throw new Error('auto-enrich requires enrich: true');
+ }
+ if (inputs.createFixPr && !inputs.autoEnrich) {
+ throw new Error('create-fix-pr requires auto-enrich: true');
+ }
+
+ return inputs;
}
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..b00d5e5 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -18,6 +18,7 @@ import { emitAnnotations } from './report/annotations';
import { uploadSarif } from './surfaces/sarif';
import { uploadResults } from './surfaces/artifact';
import { upsertComment } from './surfaces/comment';
+import { runEnrich } from './surfaces/enrich';
const SCAN_TIMEOUT_MS = 10 * 60 * 1000;
@@ -46,6 +47,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,
@@ -56,6 +63,25 @@ async function run(): Promise {
severityThreshold: inputs.severityThreshold,
});
+ // A remote URL target scans a different repo than this checkout, so the
+ // caller-repo-scoped surfaces (Code Scanning upload, PR comment) would
+ // misattribute results to this repo — skip them.
+ const remoteTarget = isRemoteTarget(inputs.target);
+
+ // Surface 5: enrich → auto-enrich → fix PR (best-effort, never fails the job).
+ // Runs before summary/comment so the fix PR URL is included in both.
+ let fixPrUrl: string | null = null;
+ if (inputs.enrich && !remoteTarget) {
+ const er = await runEnrich(installed.binPath, inputs, ctx);
+ fixPrUrl = er.fixPrUrl;
+ core.setOutput('enrich-json-file', er.enrichedJsonFile);
+ core.setOutput('fix-pr-url', er.fixPrUrl ?? '');
+ if (er.fixPrUrl) core.info(`Fix PR opened: ${er.fixPrUrl}`);
+ } else {
+ core.setOutput('enrich-json-file', '');
+ core.setOutput('fix-pr-url', '');
+ }
+
const data: ReportData = {
repoLabel: repoLabel(inputs.target, `${ctx.owner}/${ctx.repo}`),
branch: resolveBranch(inputs.branch, ctx.prHeadRef, ctx.ref),
@@ -66,8 +92,10 @@ async function run(): Promise {
nativeExit,
severityCounts: counts,
projected,
+ deps,
gate,
rulesVersion: result.rules_version,
+ fixPrUrl: fixPrUrl ?? undefined,
};
// Outputs (v1 names preserved) + one additive.
@@ -90,11 +118,6 @@ async function run(): Promise {
emitAnnotations(result.findings, inputs.maxAnnotations);
}
- // A remote URL target scans a different repo than this checkout, so the
- // caller-repo-scoped surfaces (Code Scanning upload, PR comment) would
- // misattribute results to this repo — skip them.
- const remoteTarget = isRemoteTarget(inputs.target);
-
// Surface 1b: SARIF → Security tab (needs security-events: write).
if (inputs.uploadSarif && !remoteTarget) {
const res = await uploadSarif(inputs.githubToken, ctx, inputs.sarifFile);
@@ -110,18 +133,21 @@ async function run(): Promise {
core.setOutput('sarif-uploaded', 'false');
}
- // Downloadable artifact (JSON + SARIF).
- if (inputs.uploadArtifact) {
- const days = inputs.artifactRetentionDays ? parseInt(inputs.artifactRetentionDays, 10) : undefined;
- await uploadResults(inputs.artifactName, [inputs.jsonFile, inputs.sarifFile], days);
- }
-
// Surface 2: sticky PR comment (needs pull-requests: write; pull_request only;
// skipped for a remote target — the comment would describe a different repo).
if (inputs.commentOnPr && ctx.isPullRequest && !remoteTarget) {
await upsertComment(inputs.githubToken, ctx, md);
}
+ // Downloadable artifact (JSON + SARIF + enriched.json when enrich is enabled).
+ // Runs after enrich so enriched.json is present if enrich ran.
+ if (inputs.uploadArtifact) {
+ const days = inputs.artifactRetentionDays ? parseInt(inputs.artifactRetentionDays, 10) : undefined;
+ const artifactFiles = [inputs.jsonFile, inputs.sarifFile];
+ if (inputs.enrich) artifactFiles.push('enriched.json');
+ await uploadResults(inputs.artifactName, artifactFiles, days);
+ }
+
// Surface 3: status-check gating — the job status is the check.
if (gate.fail) {
core.setFailed(`Trustabl gate failed: ${gate.reasons.join('; ')}`);
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..5db941b 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,8 @@ 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;
+ fixPrUrl?: 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..a8fb183 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}\` |`);
@@ -90,6 +94,10 @@ export function buildSummaryMarkdown(d: ReportData): string {
L.push('### ✅ Passed scanning');
}
+ if (d.fixPrUrl) {
+ L.push('', `### 🔧 Fix PR`, '', `An auto-enrich fix PR was opened: [${d.fixPrUrl}](${d.fixPrUrl})`);
+ }
+
return L.join('\n');
}
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/surfaces/enrich.ts b/src/surfaces/enrich.ts
new file mode 100644
index 0000000..a5b40c4
--- /dev/null
+++ b/src/surfaces/enrich.ts
@@ -0,0 +1,132 @@
+import * as core from '@actions/core';
+import * as exec from '@actions/exec';
+import * as github from '@actions/github';
+import { Inputs } from '../inputs';
+import { RunContext } from '../context';
+
+export interface EnrichResult {
+ enrichedJsonFile: string;
+ fixPrUrl: string | null;
+ appliedCount: number;
+}
+
+const ENRICHED_JSON = 'enriched.json';
+
+export async function runEnrich(
+ binPath: string,
+ inputs: Inputs,
+ ctx: RunContext,
+): Promise {
+ core.setSecret(inputs.llmKey);
+ try {
+ await exec.exec(binPath, ['llm', 'provider', 'set', inputs.llmProvider]);
+ await exec.exec(binPath, ['llm', 'key', 'set', inputs.llmKey], { silent: true });
+ } catch (e) {
+ core.warning(`Enrich skipped: failed to configure LLM provider/key: ${e instanceof Error ? e.message : String(e)}`);
+ return { enrichedJsonFile: ENRICHED_JSON, fixPrUrl: null, appliedCount: 0 };
+ }
+
+ if (inputs.enrichModel) {
+ try {
+ await exec.exec(binPath, ['llm', 'model', 'set', inputs.enrichModel]);
+ } catch (e) {
+ core.warning(`Enrich: failed to set model "${inputs.enrichModel}", using default: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ }
+
+ const args = [
+ 'enrich',
+ '--input', inputs.jsonFile,
+ '--repo', '.',
+ '--output', ENRICHED_JSON,
+ ];
+ if (inputs.autoEnrich) args.push('--apply');
+ for (const rule of inputs.enrichRules) args.push('--rule', rule);
+
+ try {
+ await exec.exec(binPath, args);
+ } catch (e) {
+ core.warning(`Enrich failed: ${e instanceof Error ? e.message : String(e)}`);
+ return { enrichedJsonFile: ENRICHED_JSON, fixPrUrl: null, appliedCount: 0 };
+ }
+
+ if (!inputs.autoEnrich) {
+ return { enrichedJsonFile: ENRICHED_JSON, fixPrUrl: null, appliedCount: 0 };
+ }
+
+ const modified = await getModifiedFiles(inputs);
+ if (modified.length === 0) {
+ core.info('Enrich: no files modified by auto-enrich.');
+ return { enrichedJsonFile: ENRICHED_JSON, fixPrUrl: null, appliedCount: 0 };
+ }
+
+ core.info(`Enrich: ${modified.length} file(s) patched.`);
+
+ if (!inputs.createFixPr) {
+ return { enrichedJsonFile: ENRICHED_JSON, fixPrUrl: null, appliedCount: modified.length };
+ }
+
+ const fixPrUrl = await openFixPr(inputs, ctx, modified);
+ return { enrichedJsonFile: ENRICHED_JSON, fixPrUrl, appliedCount: modified.length };
+}
+
+const SCAN_OUTPUTS = new Set([ENRICHED_JSON, 'trustabl.json', 'trustabl.sarif']);
+
+async function getModifiedFiles(inputs: Inputs): Promise {
+ const { stdout } = await exec.getExecOutput('git', ['status', '--porcelain'], { silent: true });
+ const excluded = new Set([ENRICHED_JSON, inputs.jsonFile, inputs.sarifFile]);
+ return stdout
+ .split('\n')
+ .filter((l) => l.trim())
+ .map((l) => l.slice(3).trim())
+ .filter((f) => !excluded.has(f) && !SCAN_OUTPUTS.has(f) && !f.endsWith('.trustabl.bak'));
+}
+
+async function openFixPr(inputs: Inputs, ctx: RunContext, modified: string[]): Promise {
+ const runId = github.context.runId;
+ const branch = `trustabl/fix-${runId}`;
+ const base = inputs.fixPrBase || ctx.prBaseRef || ctx.ref.replace('refs/heads/', '');
+
+ try {
+ await exec.exec('git', ['config', 'user.email', 'github-actions[bot]@users.noreply.github.com']);
+ await exec.exec('git', ['config', 'user.name', 'github-actions[bot]']);
+ await exec.exec('git', ['checkout', '-b', branch]);
+ await exec.exec('git', ['add', ...modified]);
+ await exec.exec('git', ['commit', '-m', `fix: Trustabl auto-enrich findings (run #${runId})`]);
+ await exec.exec('git', ['push', 'origin', branch]);
+
+ const octo = github.getOctokit(inputs.githubToken);
+ const { data: pr } = await octo.rest.pulls.create({
+ owner: ctx.owner,
+ repo: ctx.repo,
+ head: branch,
+ base,
+ title: `Trustabl auto-enrich — run #${runId}`,
+ body: buildFixPrBody(ctx, runId, modified),
+ });
+ return pr.html_url;
+ } catch (e) {
+ const err = e as { status?: number; message?: string };
+ if (err.status === 403) {
+ core.warning('Fix PR skipped: token lacks contents: write or pull-requests: write.');
+ } else {
+ core.warning(`Fix PR failed: ${err.message ?? String(e)}`);
+ }
+ return null;
+ }
+}
+
+function buildFixPrBody(ctx: RunContext, runId: number, modified: string[]): string {
+ const runUrl = `https://github.com/${ctx.owner}/${ctx.repo}/actions/runs/${runId}`;
+ const fileList = modified.map((f) => `- \`${f}\``).join('\n');
+ return [
+ 'Automated fixes applied by [Trustabl](https://github.com/trustabl/trustabl-action).',
+ '',
+ `**Workflow run:** ${runUrl}`,
+ '',
+ `**Patched files (${modified.length}):**`,
+ fileList,
+ '',
+ '> Review each change before merging. False-positive fixes can be closed without merging.',
+ ].join('\n');
+}
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,