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 — open-source tooling for production-ready agentic tools +

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