From b5c62151950a6d585de40f1df254becb1f94fcc0 Mon Sep 17 00:00:00 2001 From: Sato Hub Date: Sat, 3 Oct 2026 14:20:59 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Sato=20Check=20Team=20mode=20(v1.4)=20?= =?UTF-8?q?=E2=80=94=20keyed=20repo=20inventory,=20org=20policy,=20SARIF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a Sato API key (input sato-api-key or env SATO_API_KEY) the Action registers the repo in the account's inventory, applies the org policy on top of the repo's .sato/policy.json (a repo can only tighten), records the run and uploads the merged-policy SARIF to code scanning. It fails open: only a policy rule the team wrote can fail the job. No key: nothing is registered or stored; behaviour is unchanged. Keeps v1.3.1's x-sato-repo own/not-own header and template-drift mode. UA satohub-preflight-action/1.4. 39 offline tests pass. --- README.md | 43 ++++++++++- action.yml | 26 ++++++- src/custody.mjs | 8 +- src/main.mjs | 16 +++- src/team.mjs | 187 +++++++++++++++++++++++++++++++++++++++++++++ test/team.test.mjs | 131 +++++++++++++++++++++++++++++++ 6 files changed, 403 insertions(+), 8 deletions(-) create mode 100644 src/team.mjs create mode 100644 test/team.test.mjs diff --git a/README.md b/README.md index 6067cca..b88ea67 100644 --- a/README.md +++ b/README.md @@ -188,10 +188,51 @@ or the GitHub API cannot be read, the step warns, opens nothing, and exits 0 — unless you set `fail_on_error: true`. Outputs: `drift_changes`, `drift_opened`. Only `create` and `lock` are sent; nothing else from the repository leaves it. +## Sato Check Team — a key, an inventory, one policy (v1.4.0) + +Everything above is free and needs no key, in public and private repos alike. +**Sato Check Team** is the plan for a team that wants it managed in one place. +With a Sato API key the Action also: + +- **registers the repo** in your account's inventory (repo name, visibility and the + dependencies it named), so a change in what a dependency does with keys, hosts + or funds reaches your alert endpoints and receipt email; +- **applies your org policy** on top of the repo's `.sato/policy.json`. A repo can + add rules and narrow the allow-lists; it cannot loosen the org's. Waivers carry a + reason and an expiry; +- **records the run** in a history you can read at `satohub.ai/dashboard/check`; +- **uploads the merged-policy SARIF** to GitHub code scanning. + +```yaml +permissions: + contents: read + security-events: write # only for the SARIF upload + +steps: + - uses: actions/checkout@v5 + - uses: satohubai/preflight-action@v1 + with: + sato-api-key: ${{ secrets.SATO_API_KEY }} +``` + +- **No key, no change.** Without `sato-api-key` (or `SATO_API_KEY`) nothing is + registered or stored for your repo and the Action behaves as before (the + `x-sato-repo` header above is only read as an own/not-own flag). +- **It fails open.** An unreachable API, a rejected key, a plan without the feature + or a quota refusal is reported and never fails the build. The only thing that can + is a policy rule your own team wrote matching a reading. +- **What is stored, and how to remove it.** Repo name, visibility, the dependency + names and a record of each run, for keyed callers on a Sato Check Team plan only. + Delete a repo, and its history with it, at `satohub.ai/dashboard/check`. +- **Private repos** are checked the same way. The visibility header comes from the + workflow's event payload. +- Reads `package.json` manifests in this release. + ## Outputs `verdict` (the most severe seen) · `go` · `caution` · `no` · `unknown` · -`total` · `sarif` (path to a SARIF 2.1 log) · `report` (the markdown). +`total` · `sarif` (path to a SARIF 2.1 log) · `report` (the markdown) · +`team_repo_registered` · `team_sarif` (Sato Check Team mode). --- diff --git a/action.yml b/action.yml index 0b9f47b..66991a4 100644 --- a/action.yml +++ b/action.yml @@ -59,6 +59,14 @@ inputs: description: "Emit GitHub annotations on the manifest lines. true (default) or false." required: false default: "true" + sato-api-key: + description: "A Sato API key (sk_sato_…) on a Sato Check Team plan. Optional: with it the Action registers this repo in your inventory, applies your org policy and waivers, and records the run; without it nothing about the repo leaves the runner and the Action behaves as v1.2. Pass it from a secret (secrets.SATO_API_KEY); the Action masks it." + required: false + default: "" + upload-sarif: + description: "true (default) | false. In Sato Check Team mode, upload the merged-policy SARIF log to GitHub code scanning. Needs `security-events: write`; a failed upload never fails the job." + required: false + default: "true" api: description: "Sato Hub API base URL." required: false @@ -102,8 +110,14 @@ outputs: description: "template-drift only: number of issues opened by this run." value: ${{ steps.run.outputs.drift_opened }} policy_violations: - description: "Number of readings that matched a rule in the repository's .sato/policy.json (0 without a policy file)." + description: "Number of readings that matched a rule in the effective policy: the repository's .sato/policy.json, merged with your org policy in Sato Check Team mode (0 without a policy)." value: ${{ steps.run.outputs.policy_violations }} + team_repo_registered: + description: "true when Sato Check Team mode registered this repo in your inventory on this run." + value: ${{ steps.run.outputs.team_repo_registered }} + team_sarif: + description: "Path to the Sato Check Team SARIF log (empty without a key)." + value: ${{ steps.run.outputs.team_sarif }} runs: using: "composite" @@ -125,4 +139,14 @@ runs: INPUT_FAIL-ON: ${{ inputs.fail-on }} INPUT_CUSTODY: ${{ inputs.custody }} INPUT_GITHUB-TOKEN: ${{ inputs.github-token }} + INPUT_SATO-API-KEY: ${{ inputs.sato-api-key }} run: node "${{ github.action_path }}/src/main.mjs" + # Sato Check Team mode only: merged-policy results into code scanning. Runs even when + # the step above failed on a policy match, which is exactly when the alert is wanted. + - name: Upload Sato Check SARIF + if: ${{ always() && inputs.upload-sarif == 'true' && steps.run.outputs.team_sarif != '' }} + continue-on-error: true + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: ${{ steps.run.outputs.team_sarif }} + category: sato-check diff --git a/src/custody.mjs b/src/custody.mjs index c2faaea..9494a21 100644 --- a/src/custody.mjs +++ b/src/custody.mjs @@ -14,7 +14,7 @@ import { execFileSync } from "node:child_process"; import { readFileSync, existsSync } from "node:fs"; // SATO_CHECK_UA lets Sato Hub's own CI identify itself, so it is not counted as outside use. -export const UA = process.env.SATO_CHECK_UA || "satohub-preflight-action/1.2"; +export const UA = process.env.SATO_CHECK_UA || "satohub-preflight-action/1.4"; /** * `x-sato-repo: /` from GITHUB_REPOSITORY, sent on every call to the Sato Hub API. @@ -409,7 +409,7 @@ export async function upsertComment({ repo, pr, token, body, fetchFn = fetch, ap * Runs diff mode. Never throws. Returns { code, report, subjects, egress }. * `io` lets tests inject git/fetch/env. */ -export async function runCustody({ api, failOn = [], token = "", env = process.env, io = {} } = {}) { +export async function runCustody({ api, failOn = [], token = "", env = process.env, io = {}, skipPolicy = false } = {}) { const log = io.log || console.log; const fetchFn = io.fetch || fetch; const summary = io.summary || (() => {}); @@ -439,7 +439,9 @@ export async function runCustody({ api, failOn = [], token = "", env = process.e log(`::warning::Sato Check was unreachable or refused (${error}). Reported as unknown; not failing the build.`); } let policyOut = null; - const pol = (io.readPolicy || readPolicy)(); + // Sato Check Team mode (src/team.mjs) evaluates the policy on the server, after the + // org policy is merged in, so this client-side pass is skipped to avoid reporting it twice. + const pol = skipPolicy ? { policy: null } : (io.readPolicy || readPolicy)(); if (pol.error) { policyOut = { error: pol.error }; log(`::warning::Sato Check: ${pol.error}. No policy rule applied; not failing the build over it.`); diff --git a/src/main.mjs b/src/main.mjs index 9fb3005..55f1334 100644 --- a/src/main.mjs +++ b/src/main.mjs @@ -22,6 +22,7 @@ import { readFileSync, existsSync, appendFileSync, writeFileSync } from "node:fs import { resolve as resolvePath } from "node:path"; import { runCustody, UA, repoHeader } from "./custody.mjs"; import { runDrift } from "./drift.mjs"; +import { runTeam } from "./team.mjs"; const TIMEOUT_MS = 60_000; const CAVEAT = @@ -296,18 +297,27 @@ async function main() { } if (mode !== "preflight") warn(`Unknown mode "${mode}"; running the default preflight mode.`); const code = await preflight(); - if (!boolInput("custody", true)) return code; + // Sato Check Team (v1.4): only with a Sato API key. No key, nothing is registered or stored for the repo. + const key = input("sato-api-key", "") || (process.env.SATO_API_KEY || "").trim(); + const team = key + ? await runTeam({ api: input("api", "https://satohub.ai"), key, manifest: input("manifest", "package.json"), io: { summary: writeSummary } }) + : { code: 0, skipped: "no key", registered: false, sarif: "", violations: 0 }; + setOutput("team_repo_registered", team.registered ? "true" : "false"); + setOutput("team_sarif", team.sarif || ""); + if (!boolInput("custody", true)) return code || team.code; const custody = await runCustody({ api: input("api", "https://satohub.ai"), failOn: failOnList(), token: input("github-token", ""), io: { summary: writeSummary }, + // When the server evaluated the policy (org + repo, waivers applied) it is the one verdict. + skipPolicy: Boolean(key) && !team.skipped, }); if (custody.skipped) console.log(`Sato Check diff mode skipped: ${custody.skipped}.`); setOutput("custody_subjects", custody.subjects ?? 0); setOutput("key_egress_observed", custody.egress ? "true" : "false"); - setOutput("policy_violations", custody.violations ?? 0); - return code || custody.code; + setOutput("policy_violations", (custody.violations ?? 0) + (team.violations ?? 0)); + return code || team.code || custody.code; } main() diff --git a/src/team.mjs b/src/team.mjs new file mode 100644 index 0000000..e5b36d4 --- /dev/null +++ b/src/team.mjs @@ -0,0 +1,187 @@ +// Sato Check Team mode (v1.4) — the keyed half of the Action. +// +// With a Sato API key (input `sato-api-key` or env SATO_API_KEY) on a Sato Check +// Team plan, the Action sends the repo's dependency list to POST /api/check/batch +// with the key and two headers naming the repo. The server then: +// - registers the repo in the account's inventory (repo name + visibility + +// the dependencies named), so a custody change on one reaches the owner, +// - merges the account's org policy with this repo's .sato/policy.json (the +// repo can only tighten it) and applies any waivers, +// - appends the run to the account's run history. +// +// WITHOUT A KEY NOTHING HERE RUNS and the Action behaves exactly as v1.2: no +// repo name leaves the runner, no inventory exists, and the free path is intact. +// +// EVERYTHING FAILS OPEN. An unreachable API, a 401, a plan without the feature or +// a quota refusal is reported and never reddens a build. The one thing that fails +// the job is the API's own `exit_code: 1` — a policy rule YOUR team wrote matched +// a reading. A match names a fact; it is not a rating of the dependency. +// +// The key is masked in the log before anything else, and is sent only as +// `Authorization: Bearer`. + +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { resolve as resolvePath } from "node:path"; +import { UA, TIMEOUT_MS, readPolicy } from "./custody.mjs"; + +export const REPO_HEADER = "x-sato-repo"; +export const VISIBILITY_HEADER = "x-sato-repo-visibility"; + +/** Dependency names from a package.json text. Local and workspace specifiers are not installs. */ +export function readPackageTargets(text) { + let j; + try { j = JSON.parse(text || "{}"); } catch { return []; } + const deps = { ...(j.devDependencies || {}), ...(j.dependencies || {}) }; + return Object.entries(deps) + .filter(([, v]) => !/^(file:|link:|workspace:|portal:)/.test(String(v))) + .map(([name]) => name) + .sort(); +} + +/** public | private | internal | unknown, from the workflow's event payload. */ +export function repoVisibility(event) { + const r = event?.repository; + if (r?.visibility === "public" || r?.visibility === "private" || r?.visibility === "internal") return r.visibility; + if (r?.private === true) return "private"; + if (r?.private === false) return "public"; + return "unknown"; +} + +export function buildTeamRequest({ targets, policy }) { + const body = { targets }; + if (policy) body.policy = policy; + return body; +} + +export function teamHeaders({ key, repo, visibility, ua = UA }) { + return { + "content-type": "application/json", + "user-agent": ua, + authorization: `Bearer ${key}`, + ...(repo ? { [REPO_HEADER]: repo, [VISIBILITY_HEADER]: visibility || "unknown" } : {}), + }; +} + +/** SARIF 2.1.0: one `error` per policy match left AFTER the org merge and waivers. A description, never a verdict. */ +export function sarifFromResults(results, uri = "package.json") { + const loc = [{ physicalLocation: { artifactLocation: { uri } } }]; + const findings = (results || []).flatMap((r) => + (r.policy?.violations || []).map((v) => ({ + ruleId: `sato-check/${v.rule}`, + level: "error", + message: { text: `${r.subject?.id} — Sato Check policy rule ${v.rule} matched (evidence ${v.evidence_rule}): ${v.detail} A description of what was read, not a rating. ${r.check_url}` }, + locations: loc, + properties: { rule: v.rule, evidence_rule: v.evidence_rule, subject: r.subject?.id, check_url: r.check_url }, + })), + ); + return { + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + version: "2.1.0", + runs: [{ tool: { driver: { name: "Sato Check", informationUri: "https://satohub.ai/check", rules: [] } }, results: findings }], + }; +} + +const cell = (s) => String(s ?? "").replace(/\|/g, "\\|").replace(/\n/g, " "); + +/** Markdown for the job summary. Quotes the API's own wording where it has some. */ +export function renderTeam(res) { + const t = res.team || {}; + const out = ["## Sato Check Team", ""]; + out.push(`**${res.summary.profiled} of ${res.summary.total} checked** · ${res.summary.matched} policy match${res.summary.matched === 1 ? "" : "es"} · plan: ${cell(t.plan || "—")}`, ""); + if (t.repo_registered) out.push(`Inventory: \`${cell(t.repo)}\` is registered (${t.inventory_targets ?? "?"} dependencies). A change to one reaches your alert endpoints.`, ""); + else if (t.refused) out.push(`Inventory: not registered. ${cell(t.refused.error)} (rule \`${t.refused.rule}\`, limit ${t.refused.limit}, observed ${t.refused.observed}).`, ""); + else if (t.note) out.push(`Inventory: ${cell(t.note)}`, ""); + const ignored = [...(t.org_policy?.ignored?.allow_hosts || []), ...(t.org_policy?.ignored?.allow_subjects || [])]; + if (t.org_policy?.applied) out.push(`Org policy applied.${ignored.length ? ` This repo's allow-list entries the org policy does not cover were ignored: ${ignored.map((x) => `\`${cell(x)}\``).join(", ")}.` : ""}`, ""); + const rows = (res.results || []).flatMap((r) => (r.policy?.violations || []).map((v) => `| \`${cell(r.subject?.id)}\` | \`${v.rule}\` | ${cell(v.detail)} | \`${v.evidence_rule}\` |`)); + if (rows.length) out.push("| dependency | rule | what was read | evidence |", "|---|---|---|---|", ...rows, ""); + else out.push("_No reading matched a rule in the effective policy. An `unknown` answer never matches._", ""); + for (const w of t.org_policy?.waived || []) out.push(`- Waived: \`${cell(w.subject)}\` \`${w.rule}\` until ${cell(String(w.expires_at).slice(0, 10))} — ${cell(w.reason)}`); + if (t.org_policy?.waived?.length) out.push(""); + if (res.unresolved?.length) out.push(`_Not evaluated: ${res.unresolved.map((u) => `\`${cell(u.input)}\` (${cell(u.reason)})`).join(", ")}_`, ""); + out.push(`> ${res.caveat || ""}`); + return out.join("\n"); +} + +async function post(api, key, body, headers, fetchFn) { + const res = await fetchFn(`${api.replace(/\/$/, "")}/api/check/batch`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(Math.max(TIMEOUT_MS, 60_000)), + }); + const text = await res.text(); + let json = null; + try { json = JSON.parse(text); } catch { /* leave null */ } + return { status: res.status, json }; +} + +/** + * Runs team mode. Never throws. Returns { code, skipped?, registered, sarif, violations }. + * `io` lets tests inject fetch / file reads / the event payload. + */ +export async function runTeam({ api, key, manifest = "package.json", env = process.env, io = {} } = {}) { + const log = io.log || console.log; + const fetchFn = io.fetch || fetch; + const summary = io.summary || (() => {}); + if (!key) return { code: 0, skipped: "no key", registered: false, sarif: "", violations: 0 }; + log(`::add-mask::${key}`); + + const read = io.readFile || ((p) => (existsSync(p) ? readFileSync(p, "utf8") : null)); + const text = read(resolvePath(process.cwd(), manifest)); + if (text == null) { + log(`::notice::Sato Check Team: no ${manifest} to read, so there is nothing to register. Not failing the build.`); + return { code: 0, skipped: "no manifest", registered: false, sarif: "", violations: 0 }; + } + if (!/package\.json$/i.test(manifest)) { + log("::notice::Sato Check Team reads package.json manifests in this release. Not failing the build."); + return { code: 0, skipped: "manifest kind", registered: false, sarif: "", violations: 0 }; + } + const targets = readPackageTargets(text); + if (!targets.length) { + log("Sato Check Team: the manifest names no dependencies."); + return { code: 0, skipped: "no dependencies", registered: false, sarif: "", violations: 0 }; + } + + const pol = (io.readPolicy || readPolicy)(); + if (pol.error) log(`::warning::Sato Check: ${pol.error}. This repo's policy file was not sent; the org policy, if any, still applies.`); + let event = null; + try { event = JSON.parse((io.readEvent || ((p) => readFileSync(p, "utf8")))(env.GITHUB_EVENT_PATH)); } catch { /* no event payload */ } + const headers = teamHeaders({ key, repo: env.GITHUB_REPOSITORY || "", visibility: repoVisibility(event) }); + + let out; + try { + out = await post(api, key, buildTeamRequest({ targets, policy: pol.policy }), headers, fetchFn); + } catch (e) { + log(`::warning::Sato Check Team was unreachable (${e?.message || e}). Not failing the build.`); + return { code: 0, skipped: "unreachable", registered: false, sarif: "", violations: 0 }; + } + if (out.status === 401) { + log("::warning::Sato Check Team: the API key was not accepted (unknown or revoked). The free checks above still ran. Not failing the build."); + return { code: 0, skipped: "key", registered: false, sarif: "", violations: 0 }; + } + if (out.status !== 200 || !out.json?.summary) { + log(`::warning::Sato Check Team: the API answered ${out.status}${out.json?.error ? ` — ${out.json.error}` : ""}. Not failing the build.`); + return { code: 0, skipped: `http ${out.status}`, registered: false, sarif: "", violations: 0 }; + } + + const res = out.json; + const t = res.team; + if (t?.refused) log(`::notice::Sato Check Team: ${t.refused.error} (rule ${t.refused.rule}, limit ${t.refused.limit}, observed ${t.refused.observed}). The check ran in full.`); + summary(renderTeam(res)); + + let sarif = ""; + const violations = res.results.reduce((n, r) => n + (r.policy?.violations?.length || 0), 0); + try { + const path = resolvePath(env.RUNNER_TEMP || process.cwd(), "sato-check.sarif"); + (io.writeFile || writeFileSync)(path, JSON.stringify(sarifFromResults(res.results, manifest), null, 2)); + sarif = path; + } catch { /* the summary still carries the result */ } + + for (const r of res.results) { + for (const v of r.policy?.violations || []) { + log(`::error::Sato Check policy: ${r.subject?.name || r.subject?.id} matched \`${v.rule}\`: ${v.detail}`); + } + } + return { code: res.exit_code === 1 ? 1 : 0, registered: Boolean(t?.repo_registered), sarif, violations, report: renderTeam(res) }; +} diff --git a/test/team.test.mjs b/test/team.test.mjs new file mode 100644 index 0000000..c643afe --- /dev/null +++ b/test/team.test.mjs @@ -0,0 +1,131 @@ +// Offline tests for Sato Check Team mode (v1.4). `node --test test/team.test.mjs`. +import test from "node:test"; +import assert from "node:assert/strict"; +import { readPackageTargets, repoVisibility, teamHeaders, buildTeamRequest, sarifFromResults, renderTeam, runTeam, REPO_HEADER, VISIBILITY_HEADER } from "../src/team.mjs"; + +const BANNED = /\b(safe|safely|unsafe|secure|insecure|malicious|malware|scam|trusted|trustworthy|passed|risk-free|guaranteed|audited)\b/i; +const KEY = "sk_sato_" + "a".repeat(64); +const PKG = JSON.stringify({ dependencies: { viem: "^2.0.0", "@acme/wallet": "1.0.0", local: "file:../x", ws: "workspace:*" }, devDependencies: { typescript: "5" } }); + +const result = (over = {}) => ({ + schema: "sato.custody/v1", + summary: { total: 3, profiled: 3, unresolved: 0, matched: 1 }, + exit_code: 1, + results: [ + { subject: { id: "npm:@acme/wallet", name: "@acme/wallet" }, check_url: "https://satohub.ai/check/x", policy: { ok: false, violations: [{ rule: "key_egress_observed", detail: "A planted test key was sent to collector.example.net.", evidence_rule: "O-canary-egress" }] } }, + { subject: { id: "npm:viem", name: "viem" }, check_url: "https://satohub.ai/check/y", policy: { ok: true, violations: [] } }, + ], + unresolved: [], + caveat: "A profile describes what we read and ran, with dates.", + team: { plan: "Sato Check Team", repo: "acme/app", repo_registered: true, inventory_targets: 3, org_policy: { applied: true, ignored: { allow_hosts: ["evil.example"], allow_subjects: [] }, waived: [{ subject: "npm:old", rule: "new_host", reason: "Reviewed with the maintainer", expires_at: "2026-11-01T00:00:00Z" }], expired_waivers: 0 }, run_id: 7 }, + ...over, +}); + +function harness(over = {}) { + const calls = []; + const logs = []; + const files = {}; + const io = { + log: (l) => logs.push(l), + fetch: async (url, init) => { calls.push({ url, init }); return over.response ?? new Response(JSON.stringify(result()), { status: 200 }); }, + readFile: (p) => (p.endsWith("package.json") ? PKG : null), + readPolicy: () => over.policy ?? { policy: null }, + readEvent: () => JSON.stringify({ repository: { private: true } }), + writeFile: (p, c) => { files[p] = c; }, + summary: (md) => logs.push(`SUMMARY:${md}`), + }; + return { calls, logs, files, io, env: { GITHUB_REPOSITORY: "acme/app", GITHUB_EVENT_PATH: "/e", RUNNER_TEMP: "/tmp" } }; +} + +test("dependency names come from package.json; local and workspace specifiers are not installs", () => { + assert.deepEqual(readPackageTargets(PKG), ["@acme/wallet", "typescript", "viem"]); + assert.deepEqual(readPackageTargets("not json"), []); +}); + +test("visibility is read from the event payload", () => { + assert.equal(repoVisibility({ repository: { visibility: "internal", private: true } }), "internal"); + assert.equal(repoVisibility({ repository: { private: true } }), "private"); + assert.equal(repoVisibility({ repository: { private: false } }), "public"); + assert.equal(repoVisibility(null), "unknown"); +}); + +test("the key travels only as a bearer token, with the repo and its visibility named", () => { + const h = teamHeaders({ key: KEY, repo: "acme/app", visibility: "private", ua: "ua/1" }); + assert.equal(h.authorization, `Bearer ${KEY}`); + assert.equal(h[REPO_HEADER], "acme/app"); + assert.equal(h[VISIBILITY_HEADER], "private"); + assert.equal(JSON.stringify(buildTeamRequest({ targets: ["a"], policy: null })), '{"targets":["a"]}'); + assert.equal(Object.values(h).filter((v) => String(v).includes(KEY)).length, 1); +}); + +test("with no key nothing runs and nothing is sent", async () => { + const h = harness(); + const r = await runTeam({ api: "https://satohub.ai", key: "", env: h.env, io: h.io }); + assert.equal(r.skipped, "no key"); + assert.equal(h.calls.length, 0); +}); + +test("a keyed run masks the key, posts once with the repo headers, writes SARIF and fails on the API's exit_code", async () => { + const h = harness(); + const r = await runTeam({ api: "https://satohub.ai/", key: KEY, env: h.env, io: h.io }); + assert.equal(h.logs[0], `::add-mask::${KEY}`); + assert.equal(h.calls.length, 1); + assert.equal(h.calls[0].url, "https://satohub.ai/api/check/batch"); + assert.equal(h.calls[0].init.headers[REPO_HEADER], "acme/app"); + assert.equal(h.calls[0].init.headers[VISIBILITY_HEADER], "private"); + assert.deepEqual(JSON.parse(h.calls[0].init.body).targets, ["@acme/wallet", "typescript", "viem"]); + assert.equal(r.code, 1); + assert.equal(r.registered, true); + assert.equal(r.violations, 1); + const sarif = JSON.parse(Object.values(h.files)[0]); + assert.equal(sarif.version, "2.1.0"); + assert.equal(sarif.runs[0].results.length, 1); + assert.equal(sarif.runs[0].results[0].ruleId, "sato-check/key_egress_observed"); + assert.ok(h.logs.some((l) => l.startsWith("::error::Sato Check policy"))); + assert.ok(!h.logs.join("\n").replace(`::add-mask::${KEY}`, "").includes(KEY), "the key appears nowhere else in the log"); +}); + +test("the repo's own .sato/policy.json is sent for the server to merge with the org policy", async () => { + const h = harness({ policy: { policy: { version: 1, fail_on: ["new_host"] } } }); + await runTeam({ api: "https://satohub.ai", key: KEY, env: h.env, io: h.io }); + assert.deepEqual(JSON.parse(h.calls[0].init.body).policy, { version: 1, fail_on: ["new_host"] }); +}); + +test("it fails open: unreachable API, rejected key, other plan answers, odd bodies never fail the build", async () => { + for (const response of [new Response("{}", { status: 401 }), new Response("{}", { status: 503 }), new Response("nope", { status: 200 }), new Response(JSON.stringify({ error: "x" }), { status: 400 })]) { + const h = harness({ response }); + const r = await runTeam({ api: "https://satohub.ai", key: KEY, env: h.env, io: h.io }); + assert.equal(r.code, 0); + assert.ok(r.skipped); + } + const h = harness(); + h.io.fetch = async () => { throw new Error("ECONNRESET"); }; + assert.equal((await runTeam({ api: "https://satohub.ai", key: KEY, env: h.env, io: h.io })).code, 0); +}); + +test("a quota refusal is reported, the check still counts, and exit_code 0 passes the build", async () => { + const refused = result({ exit_code: 0, summary: { total: 3, profiled: 3, unresolved: 0, matched: 0 }, results: [], team: { plan: "Sato Check Team", repo: "acme/app", repo_registered: false, refused: { error: "The repo inventory: 10 of 10 used on the Sato Check Team plan.", rule: "check_repos", limit: 10, observed: 10 }, org_policy: { applied: false, ignored: { allow_hosts: [], allow_subjects: [] }, waived: [], expired_waivers: 0 } } }); + const h = harness({ response: new Response(JSON.stringify(refused), { status: 200 }) }); + const r = await runTeam({ api: "https://satohub.ai", key: KEY, env: h.env, io: h.io }); + assert.equal(r.code, 0); + assert.equal(r.registered, false); + assert.ok(h.logs.some((l) => /rule check_repos, limit 10, observed 10/.test(l))); +}); + +test("a repo with no manifest, or no dependencies, sends nothing", async () => { + const none = harness(); + none.io.readFile = () => null; + assert.equal((await runTeam({ api: "https://satohub.ai", key: KEY, env: none.env, io: none.io })).skipped, "no manifest"); + assert.equal(none.calls.length, 0); + const empty = harness(); + empty.io.readFile = () => "{}"; + assert.equal((await runTeam({ api: "https://satohub.ai", key: KEY, env: empty.env, io: empty.io })).skipped, "no dependencies"); + assert.equal(empty.calls.length, 0); +}); + +test("the summary and SARIF describe and never judge", () => { + const text = renderTeam(result()) + JSON.stringify(sarifFromResults(result().results)); + assert.ok(!BANNED.test(text.replace(/not a rating/g, "")), "no banned word"); + assert.match(renderTeam(result()), /Waived: `npm:old` `new_host` until 2026-11-01 — Reviewed with the maintainer/); + assert.match(renderTeam(result()), /allow-list entries the org policy does not cover were ignored: `evil.example`/); +});