diff --git a/docs/artifact-delivery.md b/docs/artifact-delivery.md index dc5f5cb..bdab312 100644 --- a/docs/artifact-delivery.md +++ b/docs/artifact-delivery.md @@ -345,3 +345,13 @@ publisher, or establish that the underlying source bytes or claims are true. Metadata mismatch and unavailable findings include an optional `reference` object: the declared target path, lookup column, matched data-record position, and compared source/target column names. It contains no record ID or cell value. JSON and text retain these coordinates; the offline HTML displays the first32 reference locations with up to8 mappings each and links directly to the review section. Counts still cover all reported findings. Data-record positions exclude the CSV header, including for quoted multiline records. The baseline reader continues accepting older reports without this context. Reference context uses the same relative-path, row and eight-column bounds as the current contract. It locates evidence for review; it does not fetch sources, authenticate authors or approve corrections. + +## SARIF for artifact review + +Use `skillsync artifacts --contract reviewed-contract.json --path reviewed-artifacts --format sarif` to export a SARIF 2.1.0 document to standard output. The existing exit codes remain: 0 for a passing inspection, 1 for failed artifact checks, and 2 for invalid setup. No code-scanning upload or remote request is made. + +The separate `skillsync-artifacts` driver exports stable rule IDs, encoded relative file URIs and metadata-based fingerprints. Findings without a safe relative file path keep an empty location; they are not assigned to a fabricated file. Logical CSV record positions appear as `properties.dataRecord`, because quoted multiline CSV records do not correspond to physical source lines. Raw cells, field names, reference records and free-form error messages are omitted. Use the local JSON or HTML report when reviewing the original contract. + +SARIF currently describes the current inspection. `--compare` with SARIF is rejected before opening files; use JSON or HTML for baseline transitions. GitHub ingestion and alert placement additionally depend on repository features, committed matching files and the upload workflow; a local export does not establish a successful upload or business acceptance. + +Method reference from the 2026-10-21 Trending review: [Cloudflare's structured, verified finding records](https://github.com/cloudflare/security-audit-skill/blob/c1c8a8c1471069fb0e188eeaff69b8e8db6564a8/README.md) (MIT). No audit plugin is installed or run. Format reference: [GitHub SARIF support](https://docs.github.com/en/code-security/reference/code-scanning/sarif-files/sarif-support). diff --git a/src/cli/commands/artifacts.ts b/src/cli/commands/artifacts.ts index 51cbfc8..778adc5 100644 --- a/src/cli/commands/artifacts.ts +++ b/src/cli/commands/artifacts.ts @@ -12,6 +12,7 @@ import { createArtifactReconciliationIndex, type ReconciliationSummary } from ". import { artifactSetHash, compareArtifactReports, parseArtifactReport, type ArtifactComparison } from "../../domain/artifact-report-comparison.js"; import { redactLocalPaths } from "../../reporters/local-paths.js"; import { renderArtifactsHtml } from "../../reporters/artifacts-html.js"; +import { renderArtifactsSarif } from "../../reporters/artifacts-sarif.js"; import { readDirectoryEntries, readWorkspaceFile, scanStagedWorkspace, WorkspaceTreeError } from "../../sandbox/workspace-tree.js"; const hash = (bytes: Uint8Array | string) => createHash("sha256").update(bytes).digest("hex"); @@ -172,6 +173,7 @@ async function inspectArtifacts(options: ArtifactsOptions): Promise", "SkillTape delivery bundle directory") .option("--path ", "Standalone artifact directory; choose this or --delivery") .option("--compare ", "Compare with a bounded prior artifacts JSON report from the same contract") - .option("--format ", "Output format: text, json or offline html", "text") + .option("--format ", "Output format: text, json, offline html or sarif", "text") .action(async (options: ArtifactsOptions & { format?: string }) => { - if (options.format !== "text" && options.format !== "json" && options.format !== "html") throw new Error("artifacts format must be text, json or html"); + if (!["text", "json", "html", "sarif"].includes(options.format ?? "text")) throw new Error("artifacts format must be text, json, html or sarif"); + if (options.format === "sarif" && options.compare) throw new Error("artifacts SARIF does not support --compare; use json or html"); const report = await runArtifacts({ ...options, includeRowEvidence: options.format === "html" }); io.writeOut(renderArtifacts(report, options.format)); if (report.exitCode) io.setExitCode?.(report.exitCode); diff --git a/src/reporters/artifacts-sarif.ts b/src/reporters/artifacts-sarif.ts new file mode 100644 index 0000000..a1507ab --- /dev/null +++ b/src/reporters/artifacts-sarif.ts @@ -0,0 +1,42 @@ +import { createHash } from "node:crypto"; +import type { ArtifactReport } from "../cli/commands/artifacts.js"; +import { VERSION } from "../version.js"; +import { isAbsoluteLocalPath, isRedactedLocalPath } from "./local-paths.js"; + +function relativeUri(path: string | undefined): string | undefined { + if (!path || path.length > 1024 || isAbsoluteLocalPath(path) || isRedactedLocalPath(path) || + /[\\:\p{Cc}\p{Cf}]/u.test(path) || path.split("/").some(part => !part || part === "." || part === "..")) return undefined; + try { return path.split("/").map(part => encodeURIComponent(part)).join("/"); } + catch { return undefined; } +} + +/** Export inspector metadata only: no raw values, field names, free-form messages or local roots. */ +export function renderArtifactsSarif(report: ArtifactReport): string { + const codes = [...new Set(report.findings.map(finding => finding.code))]; + const results = report.findings.map(finding => { + const uri = relativeUri(finding.path); + // CSV findings count logical data records, not physical lines (quoted records can span lines). + const dataRecord = Number.isSafeInteger(finding.row) && finding.row! > 0 ? finding.row : undefined; + const identity = JSON.stringify([finding.code, uri ?? null, dataRecord ?? null]); + return { + ruleId: finding.code, + level: "error", + message: { text: `Artifact check failed: ${finding.code}. Review the original files and contract.` }, + locations: uri ? [{ physicalLocation: { artifactLocation: { uri } } }] : [], + partialFingerprints: { "skillsyncArtifacts/v1": createHash("sha256").update(identity).digest("hex") }, + ...(dataRecord !== undefined ? { properties: { dataRecord } } : {}), + }; + }); + return JSON.stringify({ + version: "2.1.0", + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + runs: [{ + tool: { driver: { name: "skillsync-artifacts", version: VERSION, + rules: codes.map(code => ({ id: code, shortDescription: { text: `Artifact check: ${code}` } })) } }, + invocations: [{ executionSuccessful: report.exitCode !== 2 }], + results, + properties: { schema: report.schema, status: report.status, evidence: report.evidence, + execution: report.execution, provenance: report.provenance, receipt_binding: report.receipt_binding }, + }], + }, null, 2) + "\n"; +} diff --git a/tests/integration/artifacts-sarif.test.ts b/tests/integration/artifacts-sarif.test.ts new file mode 100644 index 0000000..e178bd1 --- /dev/null +++ b/tests/integration/artifacts-sarif.test.ts @@ -0,0 +1,57 @@ +import { cp, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { runCli } from "../../src/cli/index.js"; + +async function fixture(check: (root: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), "skillsync-sarif-")); + try { await cp(resolve("fixtures/product/tenant-reconciliation"), root, { recursive: true }); await check(root); } + finally { await rm(root, { recursive: true, force: true }); } +} +const command = (root: string) => ["artifacts", "--contract", join(root, "contract.json"), + "--path", join(root, "artifacts"), "--format", "sarif"]; + +describe("artifact SARIF CLI", () => { + it("exports a passing inspection with no findings", async () => fixture(async root => { + const result = await runCli(command(root)); + expect(result.exitCode).toBe(0); + const log = JSON.parse(result.stdout); + expect(log.version).toBe("2.1.0"); + expect(log.runs[0].tool.driver.name).toBe("skillsync-artifacts"); + expect(log.runs[0].results).toEqual([]); + expect(log.runs[0].properties.execution).toBe("not-run"); + expect(result.stdout.includes(root)).toBe(false); + })); + + it("preserves failed checks and stable locations without raw record values", async () => fixture(async root => { + await cp(join(root, "faults/shifted-allocations.csv"), join(root, "artifacts/allocations.csv")); + const first = await runCli(command(root)); + const second = await runCli(command(root)); + expect(first.exitCode).toBe(1); + expect(second.stdout).toBe(first.stdout); + const log = JSON.parse(first.stdout); + const run = log.runs[0]; + expect(run.results.some((finding: { ruleId: string }) => finding.ruleId === "artifact.reconciliation-sum")).toBe(true); + expect(run.results.every((finding: { level: string }) => finding.level === "error")).toBe(true); + expect(run.results.some((finding: { locations: unknown[] }) => finding.locations.length > 0)).toBe(true); + for (const sentinel of [root, "ORG_A", "ORG_B", "ORDER_1"]) expect(first.stdout.includes(sentinel)).toBe(false); + expect(run.invocations[0].executionSuccessful).toBe(true); + })); + + it("reports invalid setup as SARIF with exit 2 and no fabricated file", async () => { + const result = await runCli(command("/private/tmp/ABSENT_ARTIFACT_SENTINEL")); + expect(result.exitCode).toBe(2); + const run = JSON.parse(result.stdout).runs[0]; + expect(run.invocations[0].executionSuccessful).toBe(false); + expect(run.results[0].ruleId).toBe("artifact.contract-invalid"); + expect(run.results[0].locations).toEqual([]); + expect(result.stdout.includes("ABSENT_ARTIFACT_SENTINEL")).toBe(false); + }); + + it("rejects comparison mode before opening caller-supplied files", async () => { + const result = await runCli([...command("absent"), "--compare", "absent-baseline.json"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("SARIF does not support --compare"); + }); +}); diff --git a/tests/reporters/artifacts-sarif.test.ts b/tests/reporters/artifacts-sarif.test.ts new file mode 100644 index 0000000..f4040e0 --- /dev/null +++ b/tests/reporters/artifacts-sarif.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { renderArtifactsSarif } from "../../src/reporters/artifacts-sarif.js"; +import type { ArtifactReport } from "../../src/cli/commands/artifacts.js"; + +const report: ArtifactReport = { schema: "skillsync.artifacts-report/v1", status: "failed", exitCode: 1, + evidence: "physical-files", execution: "not-run", provenance: "not-authenticated", receipt_binding: "not-provided", + files: [], findings: [] }; + +describe("artifact SARIF metadata boundary", () => { + it("encodes relative paths and retains logical records without inventing CSV line numbers", () => { + const output = renderArtifactsSarif({ ...report, findings: [{ code: "artifact.csv-type", path: "reports/审阅 #1.csv", row: 3, + field: "PRIVATE_FIELD_SENTINEL", message: "PRIVATE_MESSAGE_SENTINEL" }] }); + const result = JSON.parse(output).runs[0].results[0]; + expect(result.locations[0].physicalLocation).toEqual({ artifactLocation: { uri: "reports/%E5%AE%A1%E9%98%85%20%231.csv" } }); + expect(result.properties.dataRecord).toBe(3); + expect(result.partialFingerprints["skillsyncArtifacts/v1"]).toMatch(/^[a-f0-9]{64}$/); + expect(output).not.toContain("PRIVATE_FIELD_SENTINEL"); + expect(output).not.toContain("PRIVATE_MESSAGE_SENTINEL"); + }); + + it.each(["/private/ROOT_SENTINEL.csv", "C:\\private\\ROOT_SENTINEL.csv", "../ROOT_SENTINEL.csv", "https://example.com/ROOT_SENTINEL", "file:///ROOT_SENTINEL", "a/../ROOT_SENTINEL", "a//ROOT_SENTINEL", "a/\u0000ROOT_SENTINEL"])("omits unsafe location %s", path => { + const output = renderArtifactsSarif({ ...report, findings: [{ code: "artifact.missing", path }] }); + expect(JSON.parse(output).runs[0].results[0].locations).toEqual([]); + expect(output).not.toContain("ROOT_SENTINEL"); + }); +});