From b45d77d3bf5d90e584b3489146de1ae9f7b244e8 Mon Sep 17 00:00:00 2001 From: "priyanshu.jindal@harness.io" Date: Mon, 27 Jul 2026 05:52:04 +0530 Subject: [PATCH 1/3] feat: [STO-12076]: add sast_remediation_diff MCP resource Expose STO DiffOccurrences so agents can list existing vs new occurrences after a validation execution. Co-authored-by: Cursor --- README.md | 21 +- src/registry/toolsets/sto.ts | 110 +++++++++++ .../sto-sast-remediation-diff.test.ts | 182 ++++++++++++++++++ 3 files changed, 303 insertions(+), 10 deletions(-) create mode 100644 tests/registry/sto-sast-remediation-diff.test.ts diff --git a/README.md b/README.md index 3be6599d9..9299c97f0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## Harness MCP Server 2.0 -An MCP (Model Context Protocol) server that gives AI agents full access to the Harness.io platform through 11 consolidated tools and 215 resource types. +An MCP (Model Context Protocol) server that gives AI agents full access to the Harness.io platform through 11 consolidated tools and 216 resource types. ## Why Use This MCP Server @@ -8,7 +8,7 @@ Most MCP servers map one tool per API endpoint. For a platform as broad as Harne This server is built differently: -- **11 tools, 215 resource types.** A registry-based dispatch system routes `harness_list`, `harness_get`, `harness_create`, etc. to any Harness resource — pipelines, services, environments, orgs, projects, feature flags, cost data, and more. The LLM picks from 11 tools instead of hundreds. +- **11 tools, 216 resource types.** A registry-based dispatch system routes `harness_list`, `harness_get`, `harness_create`, etc. to any Harness resource — pipelines, services, environments, orgs, projects, feature flags, cost data, and more. The LLM picks from 11 tools instead of hundreds. - **Full platform coverage.** 37 default toolsets spanning CI/CD, GitOps, Feature Flags, Cloud Cost Management, Security Testing, Chaos Engineering, Database DevOps, Internal Developer Portal, Software Supply Chain, Infrastructure as Code Management, Governance, Service Overrides, Knowledge Graph, and more. Opt-in Ansible coverage is available when you need inventory and playbook data. - **Multi-project workflows out of the box.** Agents discover organizations and projects dynamically — no hardcoded env vars needed. Ask "show failed executions across all projects" and the agent can navigate the full account hierarchy. - **34 prompt templates.** Pre-built prompts for common workflows: build & deploy apps end-to-end, debug failed pipelines, review DORA metrics, triage vulnerabilities, optimize cloud costs, audit access control, plan feature flag rollouts, review pull requests, approve pending pipelines, and more. @@ -1197,7 +1197,7 @@ Harness pipelines can be stored in three ways: ## Resource Types -215 resource types organized across 37 toolsets. Each resource type supports a subset of CRUD operations and optional execute actions. +216 resource types organized across 37 toolsets. Each resource type supports a subset of CRUD operations and optional execute actions. ### Platform @@ -1599,11 +1599,12 @@ SEI resources are consolidated for token efficiency. Use `metric` or `aspect` pa ### Security Testing Orchestration (STO) -| Resource Type | List | Get | Create | Update | Delete | Execute Actions | -| ----------------------- | ---- | --- | ------ | ------ | ------ | ------------------------------ | -| `security_issue` | x | | | | | | -| `security_issue_filter` | x | | | | | | -| `security_exemption` | x | | x | | | `approve`, `reject` | +| Resource Type | List | Get | Create | Update | Delete | Execute Actions | +| ------------------------- | ---- | --- | ------ | ------ | ------ | ------------------------------ | +| `security_issue` | x | | | | | | +| `security_issue_filter` | x | | | | | | +| `security_exemption` | x | | x | | | `approve`, `reject` | +| `sast_remediation_diff` | x | | | | | | `security_exemption` create is a `high_write` operation. The server derives `requester_id` from the authenticated PAT, sets `exemptFutureOccurrences=true`, and defaults `duration_days` to 30 when not provided. For listing exemptions, pass a small explicit page size (for example `filters: { "status": "Pending", "size": 5 }`) and follow the `_nextPageHint` returned in each response. @@ -1807,7 +1808,7 @@ Available toolset names: | `ccm` | cost_perspective, cost_breakdown, cost_timeseries, cost_summary, cost_recommendation, cost_anomaly, cost_anomaly_summary, cost_category, cost_account_overview, cost_filter_value, cost_recommendation_stats, cost_recommendation_detail, cost_commitment | | `sei` | sei_metric, sei_productivity_metric, sei_dora_metric, sei_team, sei_team_detail, sei_org_tree, sei_org_tree_detail, sei_business_alignment, sei_ai_usage, sei_ai_adoption, sei_ai_impact, sei_ai_raw_metric | | `scs` | scs_artifact_source, artifact_security, scs_artifact_component, scs_artifact_remediation, scs_chain_of_custody, scs_compliance_result, code_repo_security, scs_sbom | -| `sto` | security_issue, security_issue_filter, security_exemption | +| `sto` | security_issue, security_issue_filter, security_exemption, sast_remediation_diff | | `dbops` | database_schema, database_instance, database_snapshot_object, database_llm_authoring_pipeline | | `access_control` | user, user_group, service_account, role, role_assignment, resource_group, permission | | `governance` | policy, policy_set, policy_evaluation | @@ -1837,7 +1838,7 @@ Available toolset names: +--------v---------+ | Registry | <-- Declarative resource definitions | 37 Toolsets | (data files, not code) - | 215 Resource Types| + | 216 Resource Types| +--------+---------+ | +--------v---------+ diff --git a/src/registry/toolsets/sto.ts b/src/registry/toolsets/sto.ts index 72d938a24..6d8ce93cd 100644 --- a/src/registry/toolsets/sto.ts +++ b/src/registry/toolsets/sto.ts @@ -765,5 +765,115 @@ export const stoToolset: ToolsetDefinition = { }, }, }, + + // ── SAST Remediation Diff (validation vs original scan) ─────────── + { + resourceType: "sast_remediation_diff", + displayName: "SAST Remediation Diff", + description: + "Diff validation-scan occurrences against the original SAST scan's ignore set (STO DiffOccurrences). " + + "Requires BOTH `scan_id` (original scan) and `execution_id` (validation pipeline execution). " + + "Removes ignored fingerprints from the validation scan, then splits remaining occurrences into " + + "`existing` (still present from original in-scope) and `new` (introduced by the remediations). " + + "Response flattens both partitions into `items[]` tagged with `_partition`.", + searchAliases: [ + "sast rem diff", + "validation scan diff", + "sast occurrence diff", + "agent remediation validation", + ], + relatedResources: [ + { + resourceType: "pipeline_security_issue", + relationship: "sibling", + description: "Per-execution Pipeline Security issue view (existing/new partitions for a run).", + }, + { + resourceType: "security_issue", + relationship: "sibling", + description: "Cross-execution Issues page listing.", + }, + ], + toolset: "sto", + scope: "project", + scopeParams: STO_SCOPE, + identifierFields: [], + listFilterFields: [ + { name: "scan_id", description: "REQUIRED. Original STO scan id the agent remediated.", required: true }, + { name: "execution_id", description: "REQUIRED. Validation pipeline execution id.", required: true }, + { name: "only_true_positive", type: "boolean", description: "When true (default), only TRUE_POSITIVE triage verdicts are treated as in-scope on the original scan." }, + { name: "limit", type: "number", description: "Max occurrences to return (1–10000, default 1000)." }, + { name: "severity_codes", description: "Comma-separated severities.", enum: ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"] }, + { name: "exclude_repo_patterns", description: "Comma-separated glob patterns matching repository target.name to exclude." }, + ], + operations: { + list: { + method: "GET", + path: "/sto/api/v2/sast-remediation/diff", + operationPolicy: { risk: "read", retryPolicy: "safe" }, + queryParams: { + scan_id: "scanId", + execution_id: "executionId", + only_true_positive: "onlyTruePositive", + limit: "limit", + severity_codes: "severityCodes", + exclude_repo_patterns: "excludeRepoPatterns", + }, + preflight: async ({ input }) => { + if (typeof input.scan_id !== "string" || input.scan_id.length === 0) { + throw new Error( + "sast_remediation_diff: 'scan_id' is required (original STO scan id).", + ); + } + if (typeof input.execution_id !== "string" || input.execution_id.length === 0) { + throw new Error( + "sast_remediation_diff: 'execution_id' is required (validation pipeline execution id).", + ); + } + }, + responseExtractor: (raw: unknown): unknown => { + // DiffOccurrences returns: + // { validationScanId, existing: [...], new: [...], + // existingCount, newCount, matchedCount } + if (raw === null || raw === undefined || typeof raw !== "object") return raw; + const r = raw as { + validationScanId?: string; + existing?: unknown[]; + new?: unknown[]; + existingCount?: number; + newCount?: number; + matchedCount?: number; + }; + const existingItems = Array.isArray(r.existing) ? r.existing : []; + const newItems = Array.isArray(r.new) ? r.new : []; + const tagged = [ + ...existingItems.map((it) => + typeof it === "object" && it !== null ? { ...it, _partition: "existing" } : it, + ), + ...newItems.map((it) => + typeof it === "object" && it !== null ? { ...it, _partition: "new" } : it, + ), + ]; + const existingCount = + typeof r.existingCount === "number" ? r.existingCount : existingItems.length; + const newCount = typeof r.newCount === "number" ? r.newCount : newItems.length; + const matchedCount = + typeof r.matchedCount === "number" ? r.matchedCount : existingCount + newCount; + return { + items: tagged, + total: matchedCount, + existing_total: existingCount, + new_total: newCount, + matched_count: matchedCount, + validation_scan_id: r.validationScanId, + }; + }, + skipCompact: true, + description: + "Diff validation-scan occurrences vs original scan ignore set. Requires scan_id + execution_id. " + + "Flattens existing + new into items[]; each item tagged with _partition.", + }, + }, + }, ], }; diff --git a/tests/registry/sto-sast-remediation-diff.test.ts b/tests/registry/sto-sast-remediation-diff.test.ts new file mode 100644 index 000000000..202ec2102 --- /dev/null +++ b/tests/registry/sto-sast-remediation-diff.test.ts @@ -0,0 +1,182 @@ +/** + * Tests for the `sast_remediation_diff` resource that wraps + * STO DiffOccurrences (`GET /sto/api/v2/sast-remediation/diff`). + */ +import { describe, it, expect, vi } from "vitest"; +import { stoToolset } from "../../src/registry/toolsets/sto.js"; +import { Registry } from "../../src/registry/index.js"; +import type { Config } from "../../src/config.js"; +import type { HarnessClient } from "../../src/client/harness-client.js"; +import type { EndpointSpec, ResourceDefinition } from "../../src/registry/types.js"; + +function makeConfig(overrides: Partial = {}): Config { + return { + HARNESS_API_KEY: "pat.test", + HARNESS_ACCOUNT_ID: "test-account", + HARNESS_BASE_URL: "https://app.harness.io", + HARNESS_ORG: "default", + HARNESS_PROJECT: "test-project", + HARNESS_API_TIMEOUT_MS: 30000, + HARNESS_MAX_RETRIES: 3, + LOG_LEVEL: "info", + HARNESS_MAX_BODY_SIZE_MB: 10, + HARNESS_RATE_LIMIT_RPS: 10, + HARNESS_READ_ONLY: false, + HARNESS_SKIP_ELICITATION: false, + HARNESS_ALLOW_HTTP: false, + HARNESS_FME_BASE_URL: "https://api.split.io", + ...overrides, + } as Config; +} + +function makeClient(requestFn?: (...args: unknown[]) => unknown): HarnessClient { + return { + request: requestFn ?? vi.fn().mockResolvedValue({}), + account: "test-account", + } as unknown as HarnessClient; +} + +function getResource(): ResourceDefinition { + const r = stoToolset.resources.find((x) => x.resourceType === "sast_remediation_diff"); + if (!r) throw new Error("sast_remediation_diff resource not registered"); + return r; +} + +function getListSpec(): EndpointSpec { + const spec = getResource().operations.list; + if (!spec) throw new Error("sast_remediation_diff.list spec missing"); + return spec; +} + +describe("sast_remediation_diff resource registration", () => { + it("registers list-only GET against sast-remediation/diff", () => { + const resource = getResource(); + const list = getListSpec(); + expect(resource.scope).toBe("project"); + expect(resource.scopeParams).toEqual({ + account: "accountId", + org: "orgId", + project: "projectId", + }); + expect(list.method).toBe("GET"); + expect(list.path).toBe("/sto/api/v2/sast-remediation/diff"); + expect(list.queryParams).toMatchObject({ + scan_id: "scanId", + execution_id: "executionId", + only_true_positive: "onlyTruePositive", + limit: "limit", + severity_codes: "severityCodes", + exclude_repo_patterns: "excludeRepoPatterns", + }); + expect(list.operationPolicy).toEqual({ risk: "read", retryPolicy: "safe" }); + }); +}); + +describe("sast_remediation_diff preflight", () => { + it("rejects missing scan_id", async () => { + const spec = getListSpec(); + await expect( + spec.preflight!({ + client: { account: "test-account" }, + input: { execution_id: "exec-1" }, + registry: { dispatch: async () => undefined, getResource }, + }), + ).rejects.toThrow(/scan_id/); + }); + + it("rejects missing execution_id", async () => { + const spec = getListSpec(); + await expect( + spec.preflight!({ + client: { account: "test-account" }, + input: { scan_id: "scan-1" }, + registry: { dispatch: async () => undefined, getResource }, + }), + ).rejects.toThrow(/execution_id/); + }); + + it("accepts both required ids", async () => { + const spec = getListSpec(); + await expect( + spec.preflight!({ + client: { account: "test-account" }, + input: { scan_id: "scan-1", execution_id: "exec-1" }, + registry: { dispatch: async () => undefined, getResource }, + }), + ).resolves.toBeUndefined(); + }); +}); + +describe("sast_remediation_diff responseExtractor", () => { + const MOCK_API_RESPONSE = { + validationScanId: "val-scan-1", + existing: [{ issueId: "iss-e1", occurrenceInternalId: 1, fingerprint: "fp-e" }], + new: [{ issueId: "iss-n1", occurrenceInternalId: 2, fingerprint: "fp-n" }], + existingCount: 1, + newCount: 1, + matchedCount: 2, + }; + + it("flattens existing+new with _partition tags and side-channels", () => { + const spec = getListSpec(); + const result = spec.responseExtractor!(MOCK_API_RESPONSE, {}) as Record; + + expect(result.items).toEqual([ + { issueId: "iss-e1", occurrenceInternalId: 1, fingerprint: "fp-e", _partition: "existing" }, + { issueId: "iss-n1", occurrenceInternalId: 2, fingerprint: "fp-n", _partition: "new" }, + ]); + expect(result.total).toBe(2); + expect(result.existing_total).toBe(1); + expect(result.new_total).toBe(1); + expect(result.matched_count).toBe(2); + expect(result.validation_scan_id).toBe("val-scan-1"); + }); + + it("handles null/undefined API response without throwing", () => { + const spec = getListSpec(); + expect(spec.responseExtractor!(null, {})).toBeNull(); + expect(spec.responseExtractor!(undefined, {})).toBeUndefined(); + }); +}); + +describe("sast_remediation_diff — registry dispatch", () => { + it("passes required query params through to the Harness client", async () => { + const request = vi.fn().mockResolvedValue({ + validationScanId: "val-scan-1", + existing: [], + new: [], + existingCount: 0, + newCount: 0, + matchedCount: 0, + }); + const client = makeClient(request); + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "sto" })); + + await registry.dispatch(client, "sast_remediation_diff", "list", { + scan_id: "orig-scan", + execution_id: "val-exec", + only_true_positive: false, + limit: 50, + severity_codes: "HIGH,CRITICAL", + }); + + expect(request).toHaveBeenCalledTimes(1); + const call = request.mock.calls[0]![0] as { + method: string; + path: string; + params: Record; + }; + expect(call.method).toBe("GET"); + expect(call.path).toBe("/sto/api/v2/sast-remediation/diff"); + expect(call.params).toMatchObject({ + accountId: "test-account", + orgId: "default", + projectId: "test-project", + scanId: "orig-scan", + executionId: "val-exec", + onlyTruePositive: false, + limit: 50, + severityCodes: "HIGH,CRITICAL", + }); + }); +}); From ff5e554ff02746a2f1ff030423bc125655d3da15 Mon Sep 17 00:00:00 2001 From: "priyanshu.jindal@harness.io" Date: Mon, 27 Jul 2026 05:57:36 +0530 Subject: [PATCH 2/3] fix: [STO-12076]: move sast rem diff extractor to extractors.ts Architecture guardrail caps inline responseExtractor count in sto.ts. Co-authored-by: Cursor --- src/registry/extractors.ts | 46 ++++++++++++++++++++++++++++++++++++ src/registry/toolsets/sto.ts | 40 ++----------------------------- 2 files changed, 48 insertions(+), 38 deletions(-) diff --git a/src/registry/extractors.ts b/src/registry/extractors.ts index 0bb70607e..ece979c78 100644 --- a/src/registry/extractors.ts +++ b/src/registry/extractors.ts @@ -127,6 +127,52 @@ export const stoExemptionsExtract = (raw: unknown, input?: Record { + if (raw === null || raw === undefined || typeof raw !== "object") return raw; + const r = raw as { + validationScanId?: string; + existing?: unknown[]; + new?: unknown[]; + existingCount?: number; + newCount?: number; + matchedCount?: number; + }; + const existingItems = Array.isArray(r.existing) ? r.existing : []; + const newItems = Array.isArray(r.new) ? r.new : []; + const tagged = [ + ...existingItems.map((it) => + typeof it === "object" && it !== null ? { ...it, _partition: "existing" } : it, + ), + ...newItems.map((it) => + typeof it === "object" && it !== null ? { ...it, _partition: "new" } : it, + ), + ]; + const existingCount = + typeof r.existingCount === "number" ? r.existingCount : existingItems.length; + const newCount = typeof r.newCount === "number" ? r.newCount : newItems.length; + const matchedCount = + typeof r.matchedCount === "number" ? r.matchedCount : existingCount + newCount; + return { + items: tagged, + total: matchedCount, + existing_total: existingCount, + new_total: newCount, + matched_count: matchedCount, + validation_scan_id: r.validationScanId, + }; +}; + /** * AI Evals control plane — paginated list: `{ data, page, limit, total_elements }`. */ diff --git a/src/registry/toolsets/sto.ts b/src/registry/toolsets/sto.ts index 6d8ce93cd..bc9c2ac5e 100644 --- a/src/registry/toolsets/sto.ts +++ b/src/registry/toolsets/sto.ts @@ -1,5 +1,5 @@ import type { ToolsetDefinition } from "../types.js"; -import { passthrough, stoExemptionsExtract } from "../extractors.js"; +import { passthrough, stoExemptionsExtract, stoSastRemediationDiffExtract } from "../extractors.js"; /** * Injects a redirect hint into every security_issue list response. @@ -831,43 +831,7 @@ export const stoToolset: ToolsetDefinition = { ); } }, - responseExtractor: (raw: unknown): unknown => { - // DiffOccurrences returns: - // { validationScanId, existing: [...], new: [...], - // existingCount, newCount, matchedCount } - if (raw === null || raw === undefined || typeof raw !== "object") return raw; - const r = raw as { - validationScanId?: string; - existing?: unknown[]; - new?: unknown[]; - existingCount?: number; - newCount?: number; - matchedCount?: number; - }; - const existingItems = Array.isArray(r.existing) ? r.existing : []; - const newItems = Array.isArray(r.new) ? r.new : []; - const tagged = [ - ...existingItems.map((it) => - typeof it === "object" && it !== null ? { ...it, _partition: "existing" } : it, - ), - ...newItems.map((it) => - typeof it === "object" && it !== null ? { ...it, _partition: "new" } : it, - ), - ]; - const existingCount = - typeof r.existingCount === "number" ? r.existingCount : existingItems.length; - const newCount = typeof r.newCount === "number" ? r.newCount : newItems.length; - const matchedCount = - typeof r.matchedCount === "number" ? r.matchedCount : existingCount + newCount; - return { - items: tagged, - total: matchedCount, - existing_total: existingCount, - new_total: newCount, - matched_count: matchedCount, - validation_scan_id: r.validationScanId, - }; - }, + responseExtractor: stoSastRemediationDiffExtract, skipCompact: true, description: "Diff validation-scan occurrences vs original scan ignore set. Requires scan_id + execution_id. " From c15d8768e94c8f6dcb3063935e46ced6159a6e87 Mon Sep 17 00:00:00 2001 From: "priyanshu.jindal@harness.io" Date: Mon, 27 Jul 2026 17:58:16 +0530 Subject: [PATCH 3/3] feat: [STO-12076]: Sast remediation diff API update --- src/registry/extractors.ts | 20 ++- src/registry/toolsets/sto.ts | 43 ++++-- .../sto-sast-remediation-diff.test.ts | 122 +++++++++++++++--- 3 files changed, 152 insertions(+), 33 deletions(-) diff --git a/src/registry/extractors.ts b/src/registry/extractors.ts index ece979c78..f3483aac0 100644 --- a/src/registry/extractors.ts +++ b/src/registry/extractors.ts @@ -129,27 +129,39 @@ export const stoExemptionsExtract = (raw: unknown, input?: Record { if (raw === null || raw === undefined || typeof raw !== "object") return raw; const r = raw as { validationScanId?: string; + existingOccurrences?: unknown[]; + newOccurrences?: unknown[]; + // Legacy keys (pre rename) — keep reading briefly for mixed deploys. existing?: unknown[]; new?: unknown[]; existingCount?: number; newCount?: number; matchedCount?: number; }; - const existingItems = Array.isArray(r.existing) ? r.existing : []; - const newItems = Array.isArray(r.new) ? r.new : []; + const existingItems = Array.isArray(r.existingOccurrences) + ? r.existingOccurrences + : Array.isArray(r.existing) + ? r.existing + : []; + const newItems = Array.isArray(r.newOccurrences) + ? r.newOccurrences + : Array.isArray(r.new) + ? r.new + : []; const tagged = [ ...existingItems.map((it) => typeof it === "object" && it !== null ? { ...it, _partition: "existing" } : it, diff --git a/src/registry/toolsets/sto.ts b/src/registry/toolsets/sto.ts index bc9c2ac5e..5aa9e2b81 100644 --- a/src/registry/toolsets/sto.ts +++ b/src/registry/toolsets/sto.ts @@ -772,10 +772,11 @@ export const stoToolset: ToolsetDefinition = { displayName: "SAST Remediation Diff", description: "Diff validation-scan occurrences against the original SAST scan's ignore set (STO DiffOccurrences). " - + "Requires BOTH `scan_id` (original scan) and `execution_id` (validation pipeline execution). " - + "Removes ignored fingerprints from the validation scan, then splits remaining occurrences into " + + "Requires BOTH `scan_id` (original scan) and `validation_execution_id` (validation pipeline execution). " + + "Removes ignored fingerprints from the validation scan (server-side), then splits remaining occurrences into " + "`existing` (still present from original in-scope) and `new` (introduced by the remediations). " - + "Response flattens both partitions into `items[]` tagged with `_partition`.", + + "Response flattens both partitions into `items[]` tagged with `_partition`. " + + "Fingerprint is not returned on items — matching stays inside STO Core.", searchAliases: [ "sast rem diff", "validation scan diff", @@ -800,7 +801,19 @@ export const stoToolset: ToolsetDefinition = { identifierFields: [], listFilterFields: [ { name: "scan_id", description: "REQUIRED. Original STO scan id the agent remediated.", required: true }, - { name: "execution_id", description: "REQUIRED. Validation pipeline execution id.", required: true }, + { + name: "validation_execution_id", + description: + "REQUIRED (or legacy alias execution_id). Validation pipeline execution id " + + "(sto-core query param validationExecutionId).", + required: false, + }, + { + name: "execution_id", + description: + "Legacy alias for validation_execution_id. Prefer validation_execution_id.", + required: false, + }, { name: "only_true_positive", type: "boolean", description: "When true (default), only TRUE_POSITIVE triage verdicts are treated as in-scope on the original scan." }, { name: "limit", type: "number", description: "Max occurrences to return (1–10000, default 1000)." }, { name: "severity_codes", description: "Comma-separated severities.", enum: ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"] }, @@ -809,11 +822,13 @@ export const stoToolset: ToolsetDefinition = { operations: { list: { method: "GET", - path: "/sto/api/v2/sast-remediation/diff", + path: "/sto/api/v2/sast-remediation/diff-occurrences", operationPolicy: { risk: "read", retryPolicy: "safe" }, queryParams: { scan_id: "scanId", - execution_id: "executionId", + validation_execution_id: "validationExecutionId", + // Legacy alias — prefer validation_execution_id. + execution_id: "validationExecutionId", only_true_positive: "onlyTruePositive", limit: "limit", severity_codes: "severityCodes", @@ -825,17 +840,25 @@ export const stoToolset: ToolsetDefinition = { "sast_remediation_diff: 'scan_id' is required (original STO scan id).", ); } - if (typeof input.execution_id !== "string" || input.execution_id.length === 0) { + const validationExecutionId = + (typeof input.validation_execution_id === "string" && input.validation_execution_id) + || (typeof input.execution_id === "string" && input.execution_id) + || ""; + if (!validationExecutionId) { throw new Error( - "sast_remediation_diff: 'execution_id' is required (validation pipeline execution id).", + "sast_remediation_diff: 'validation_execution_id' is required " + + "(validation pipeline execution id; alias: execution_id).", ); } + // Normalize so queryParams maps a single canonical key. + input.validation_execution_id = validationExecutionId; + delete input.execution_id; }, responseExtractor: stoSastRemediationDiffExtract, skipCompact: true, description: - "Diff validation-scan occurrences vs original scan ignore set. Requires scan_id + execution_id. " - + "Flattens existing + new into items[]; each item tagged with _partition.", + "Diff validation-scan occurrences vs original scan ignore set. Requires scan_id + validation_execution_id. " + + "Flattens existingOccurrences + newOccurrences into items[]; each item tagged with _partition.", }, }, }, diff --git a/tests/registry/sto-sast-remediation-diff.test.ts b/tests/registry/sto-sast-remediation-diff.test.ts index 202ec2102..f79a49256 100644 --- a/tests/registry/sto-sast-remediation-diff.test.ts +++ b/tests/registry/sto-sast-remediation-diff.test.ts @@ -1,6 +1,6 @@ /** * Tests for the `sast_remediation_diff` resource that wraps - * STO DiffOccurrences (`GET /sto/api/v2/sast-remediation/diff`). + * STO DiffOccurrences (`GET /sto/api/v2/sast-remediation/diff-occurrences`). */ import { describe, it, expect, vi } from "vitest"; import { stoToolset } from "../../src/registry/toolsets/sto.js"; @@ -49,7 +49,7 @@ function getListSpec(): EndpointSpec { } describe("sast_remediation_diff resource registration", () => { - it("registers list-only GET against sast-remediation/diff", () => { + it("registers list-only GET against sast-remediation/diff-occurrences", () => { const resource = getResource(); const list = getListSpec(); expect(resource.scope).toBe("project"); @@ -59,10 +59,11 @@ describe("sast_remediation_diff resource registration", () => { project: "projectId", }); expect(list.method).toBe("GET"); - expect(list.path).toBe("/sto/api/v2/sast-remediation/diff"); + expect(list.path).toBe("/sto/api/v2/sast-remediation/diff-occurrences"); expect(list.queryParams).toMatchObject({ scan_id: "scanId", - execution_id: "executionId", + validation_execution_id: "validationExecutionId", + execution_id: "validationExecutionId", only_true_positive: "onlyTruePositive", limit: "limit", severity_codes: "severityCodes", @@ -78,13 +79,13 @@ describe("sast_remediation_diff preflight", () => { await expect( spec.preflight!({ client: { account: "test-account" }, - input: { execution_id: "exec-1" }, + input: { validation_execution_id: "exec-1" }, registry: { dispatch: async () => undefined, getResource }, }), ).rejects.toThrow(/scan_id/); }); - it("rejects missing execution_id", async () => { + it("rejects missing validation_execution_id (and legacy execution_id)", async () => { const spec = getListSpec(); await expect( spec.preflight!({ @@ -92,38 +93,76 @@ describe("sast_remediation_diff preflight", () => { input: { scan_id: "scan-1" }, registry: { dispatch: async () => undefined, getResource }, }), - ).rejects.toThrow(/execution_id/); + ).rejects.toThrow(/validation_execution_id/); }); - it("accepts both required ids", async () => { + it("accepts validation_execution_id", async () => { const spec = getListSpec(); + const input: Record = { + scan_id: "scan-1", + validation_execution_id: "exec-1", + }; + await expect( + spec.preflight!({ + client: { account: "test-account" }, + input, + registry: { dispatch: async () => undefined, getResource }, + }), + ).resolves.toBeUndefined(); + expect(input.validation_execution_id).toBe("exec-1"); + }); + + it("accepts legacy execution_id and normalizes to validation_execution_id", async () => { + const spec = getListSpec(); + const input: Record = { + scan_id: "scan-1", + execution_id: "exec-legacy", + }; await expect( spec.preflight!({ client: { account: "test-account" }, - input: { scan_id: "scan-1", execution_id: "exec-1" }, + input, registry: { dispatch: async () => undefined, getResource }, }), ).resolves.toBeUndefined(); + expect(input.validation_execution_id).toBe("exec-legacy"); + expect(input.execution_id).toBeUndefined(); }); }); describe("sast_remediation_diff responseExtractor", () => { const MOCK_API_RESPONSE = { validationScanId: "val-scan-1", - existing: [{ issueId: "iss-e1", occurrenceInternalId: 1, fingerprint: "fp-e" }], - new: [{ issueId: "iss-n1", occurrenceInternalId: 2, fingerprint: "fp-n" }], + existingOccurrences: [ + { issueId: "iss-e1", occurrenceInternalId: 1, issueTitle: "t1", severityCode: "HIGH" }, + ], + newOccurrences: [ + { issueId: "iss-n1", occurrenceInternalId: 2, issueTitle: "t2", severityCode: "LOW" }, + ], existingCount: 1, newCount: 1, matchedCount: 2, }; - it("flattens existing+new with _partition tags and side-channels", () => { + it("flattens existingOccurrences+newOccurrences with _partition tags", () => { const spec = getListSpec(); const result = spec.responseExtractor!(MOCK_API_RESPONSE, {}) as Record; expect(result.items).toEqual([ - { issueId: "iss-e1", occurrenceInternalId: 1, fingerprint: "fp-e", _partition: "existing" }, - { issueId: "iss-n1", occurrenceInternalId: 2, fingerprint: "fp-n", _partition: "new" }, + { + issueId: "iss-e1", + occurrenceInternalId: 1, + issueTitle: "t1", + severityCode: "HIGH", + _partition: "existing", + }, + { + issueId: "iss-n1", + occurrenceInternalId: 2, + issueTitle: "t2", + severityCode: "LOW", + _partition: "new", + }, ]); expect(result.total).toBe(2); expect(result.existing_total).toBe(1); @@ -132,6 +171,26 @@ describe("sast_remediation_diff responseExtractor", () => { expect(result.validation_scan_id).toBe("val-scan-1"); }); + it("falls back to legacy existing/new keys", () => { + const spec = getListSpec(); + const result = spec.responseExtractor!( + { + validationScanId: "val-scan-1", + existing: [{ issueId: "iss-e1", occurrenceInternalId: 1 }], + new: [{ issueId: "iss-n1", occurrenceInternalId: 2 }], + existingCount: 1, + newCount: 1, + matchedCount: 2, + }, + {}, + ) as Record; + + expect(result.items).toEqual([ + { issueId: "iss-e1", occurrenceInternalId: 1, _partition: "existing" }, + { issueId: "iss-n1", occurrenceInternalId: 2, _partition: "new" }, + ]); + }); + it("handles null/undefined API response without throwing", () => { const spec = getListSpec(); expect(spec.responseExtractor!(null, {})).toBeNull(); @@ -143,8 +202,8 @@ describe("sast_remediation_diff — registry dispatch", () => { it("passes required query params through to the Harness client", async () => { const request = vi.fn().mockResolvedValue({ validationScanId: "val-scan-1", - existing: [], - new: [], + existingOccurrences: [], + newOccurrences: [], existingCount: 0, newCount: 0, matchedCount: 0, @@ -154,7 +213,7 @@ describe("sast_remediation_diff — registry dispatch", () => { await registry.dispatch(client, "sast_remediation_diff", "list", { scan_id: "orig-scan", - execution_id: "val-exec", + validation_execution_id: "val-exec", only_true_positive: false, limit: 50, severity_codes: "HIGH,CRITICAL", @@ -167,16 +226,41 @@ describe("sast_remediation_diff — registry dispatch", () => { params: Record; }; expect(call.method).toBe("GET"); - expect(call.path).toBe("/sto/api/v2/sast-remediation/diff"); + expect(call.path).toBe("/sto/api/v2/sast-remediation/diff-occurrences"); expect(call.params).toMatchObject({ accountId: "test-account", orgId: "default", projectId: "test-project", scanId: "orig-scan", - executionId: "val-exec", + validationExecutionId: "val-exec", onlyTruePositive: false, limit: 50, severityCodes: "HIGH,CRITICAL", }); + expect(call.params.executionId).toBeUndefined(); + }); + + it("normalizes legacy execution_id filter to validationExecutionId", async () => { + const request = vi.fn().mockResolvedValue({ + validationScanId: "val-scan-1", + existingOccurrences: [], + newOccurrences: [], + existingCount: 0, + newCount: 0, + matchedCount: 0, + }); + const client = makeClient(request); + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "sto" })); + + await registry.dispatch(client, "sast_remediation_diff", "list", { + scan_id: "orig-scan", + execution_id: "val-exec-legacy", + }); + + const call = request.mock.calls[0]![0] as { + params: Record; + }; + expect(call.params.validationExecutionId).toBe("val-exec-legacy"); + expect(call.params.executionId).toBeUndefined(); }); });