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
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
26 changes: 25 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
8 changes: 5 additions & 3 deletions src/custody.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: <owner>/<repo>` from GITHUB_REPOSITORY, sent on every call to the Sato Hub API.
Expand Down Expand Up @@ -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 || (() => {});
Expand Down Expand Up @@ -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.`);
Expand Down
16 changes: 13 additions & 3 deletions src/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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()
Expand Down
187 changes: 187 additions & 0 deletions src/team.mjs
Original file line number Diff line number Diff line change
@@ -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) };
}
Loading
Loading