Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/artifact-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 2 additions & 0 deletions src/cli/commands/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -172,6 +173,7 @@ async function inspectArtifacts(options: ArtifactsOptions): Promise<ArtifactRepo

export function renderArtifacts(report: ArtifactReport, format = "text"): string {
if (format === "html") return renderArtifactsHtml(report);
if (format === "sarif") return renderArtifactsSarif(report);
const safe = redactLocalPaths(report);
if (format === "json") return JSON.stringify(safe, null, 2) + "\n";
return [`Artifact validation: ${safe.status}`, `Evidence: physical files; execution: not-run; receipt binding: ${safe.receipt_binding}`,
Expand Down
5 changes: 3 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,9 +576,10 @@ export function createCli(io: CliIO = defaultCliIO): Command {
.option("--delivery <path>", "SkillTape delivery bundle directory")
.option("--path <path>", "Standalone artifact directory; choose this or --delivery")
.option("--compare <path>", "Compare with a bounded prior artifacts JSON report from the same contract")
.option("--format <format>", "Output format: text, json or offline html", "text")
.option("--format <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);
Expand Down
42 changes: 42 additions & 0 deletions src/reporters/artifacts-sarif.ts
Original file line number Diff line number Diff line change
@@ -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";
}
57 changes: 57 additions & 0 deletions tests/integration/artifacts-sarif.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>) {
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");
});
});
26 changes: 26 additions & 0 deletions tests/reporters/artifacts-sarif.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading