@synovitec/trellis 0.0.0-stage → 0.6.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +393 -0
- package/LICENSE +190 -0
- package/README.md +206 -2
- package/SECURITY.md +104 -0
- package/bin/trellis.mjs +5 -0
- package/docs/attestation/v0.1.md +96 -0
- package/package.json +78 -4
- package/schema/trellis.config.schema.json +349 -0
- package/src/agents/adapters.mjs +155 -0
- package/src/agents/git.mjs +241 -0
- package/src/agents/glob.mjs +41 -0
- package/src/agents/hooks.mjs +780 -0
- package/src/agents/install-adapters.mjs +172 -0
- package/src/agents/install.mjs +302 -0
- package/src/agents/manifests.mjs +127 -0
- package/src/agents/rules.mjs +330 -0
- package/src/agents/shell.mjs +253 -0
- package/src/agents/state.mjs +233 -0
- package/src/agents/transcript.mjs +120 -0
- package/src/cli/main.mjs +191 -0
- package/src/commands/attest.mjs +207 -0
- package/src/commands/checkpoint-state.mjs +62 -0
- package/src/commands/doctor.mjs +98 -0
- package/src/commands/guide.mjs +62 -0
- package/src/commands/init.mjs +129 -0
- package/src/commands/key.mjs +31 -0
- package/src/commands/scan.mjs +51 -0
- package/src/commands/uninstall.mjs +36 -0
- package/src/commands/verify.mjs +509 -0
- package/src/core/abatty.mjs +25 -0
- package/src/core/agents-block.mjs +65 -0
- package/src/core/attestation.mjs +213 -0
- package/src/core/config.mjs +135 -0
- package/src/core/context.mjs +280 -0
- package/src/core/diff.mjs +189 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/mcp.mjs +51 -0
- package/src/index.mjs +11 -0
- package/src/mcp/client.mjs +164 -0
- package/src/mcp/launch.mjs +57 -0
- package/src/mcp/relay.mjs +111 -0
- package/src/mcp/server.mjs +210 -0
- package/src/mcp/trust.mjs +142 -0
- package/src/tools/registry.mjs +370 -0
- package/src/tools/roles.mjs +17 -0
- package/src/verify/deps.mjs +382 -0
- package/src/verify/gitleaks-default.toml +3211 -0
- package/src/verify/rules.mjs +163 -0
- package/src/verify/secrets.mjs +169 -0
- package/src/verify/test-commands.mjs +116 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The rules a change is held to at the commit checkpoint, as a pure function: a change (paths and
|
|
2
|
+
// blobs), the evidence window, who made it, and the results of the checks Trellis ran itself.
|
|
3
|
+
// Which rule blocks and which warns follows the evidence (docs/design/0001-evidence-v2.md):
|
|
4
|
+
// block only what is deterministic; warn on the heuristic rules.
|
|
5
|
+
import { matchesAny } from "../agents/glob.mjs";
|
|
6
|
+
import { DEFAULTS } from "../agents/rules.mjs";
|
|
7
|
+
import { isTestCommand } from "./test-commands.mjs";
|
|
8
|
+
|
|
9
|
+
/** @typedef {"block" | "warn" | "off"} Mode */
|
|
10
|
+
/** @typedef {{ rule: string, mode: "block" | "warn", message: string }} Finding */
|
|
11
|
+
|
|
12
|
+
/** @type {Record<string, Record<string, Mode>>} */
|
|
13
|
+
export const PRESETS = Object.freeze({
|
|
14
|
+
recommended: {
|
|
15
|
+
"secrets-scanned": "block",
|
|
16
|
+
"deps-verified": "block",
|
|
17
|
+
"deps-pinned": "warn",
|
|
18
|
+
"deps-registry": "warn",
|
|
19
|
+
"tests-protected": "block",
|
|
20
|
+
escalation: "block",
|
|
21
|
+
"tests-run": "warn",
|
|
22
|
+
"graph-before-edit": "warn",
|
|
23
|
+
"docs-looked-up": "warn",
|
|
24
|
+
"ui-verified": "warn",
|
|
25
|
+
"schema-before-change": "warn",
|
|
26
|
+
review: "off",
|
|
27
|
+
"plan-before-change": "off",
|
|
28
|
+
},
|
|
29
|
+
strict: {
|
|
30
|
+
"secrets-scanned": "block",
|
|
31
|
+
"deps-verified": "block",
|
|
32
|
+
"deps-pinned": "warn",
|
|
33
|
+
"deps-registry": "block",
|
|
34
|
+
"tests-protected": "block",
|
|
35
|
+
escalation: "block",
|
|
36
|
+
"tests-run": "block",
|
|
37
|
+
"graph-before-edit": "warn",
|
|
38
|
+
"docs-looked-up": "warn",
|
|
39
|
+
"ui-verified": "warn",
|
|
40
|
+
"schema-before-change": "warn",
|
|
41
|
+
review: "warn",
|
|
42
|
+
"plan-before-change": "warn",
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** Test files: an agent may add new ones, never change or delete existing ones. */
|
|
47
|
+
const TEST_FILES = Object.freeze(["**/*.{test,spec}.*", "**/test/**", "**/tests/**", "**/__tests__/**", "**/spec/**", "**/*_spec.rb", "**/*_test.go", "**/test_*.py", "**/*_test.py"]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What decides which tests run and what judges them: a new one bends as much as an edited one
|
|
51
|
+
* (a conftest.py, a pytest.ini or a vitest.config that deselects every test), so an agent may
|
|
52
|
+
* neither add nor change one without a person.
|
|
53
|
+
*/
|
|
54
|
+
const GATES = Object.freeze([
|
|
55
|
+
"**/conftest.py",
|
|
56
|
+
"**/{setupTests,jest.setup,vitest.setup}.*",
|
|
57
|
+
"**/{jest,vitest,playwright,cypress,karma,vite}.config.*",
|
|
58
|
+
"**/vitest.workspace.*",
|
|
59
|
+
"**/.mocharc*",
|
|
60
|
+
"**/{pytest,tox}.ini",
|
|
61
|
+
".github/workflows/**",
|
|
62
|
+
".gitlab-ci.yml",
|
|
63
|
+
".woodpecker.yml",
|
|
64
|
+
".woodpecker/**",
|
|
65
|
+
".circleci/**",
|
|
66
|
+
"azure-pipelines.yml",
|
|
67
|
+
"Jenkinsfile",
|
|
68
|
+
"bitbucket-pipelines.yml",
|
|
69
|
+
".github/actions/**",
|
|
70
|
+
// The checkpoint's own settings and gates: bending them passes everything at once.
|
|
71
|
+
"trellis.config.json",
|
|
72
|
+
".trellis/**",
|
|
73
|
+
".gitleaks.toml",
|
|
74
|
+
".gitleaksignore",
|
|
75
|
+
".githooks/**",
|
|
76
|
+
".husky/**",
|
|
77
|
+
// The agents' own hooks and MCP configs: the checks an agent runs under.
|
|
78
|
+
".claude/settings.json",
|
|
79
|
+
".cursor/{hooks,mcp}.json",
|
|
80
|
+
".codex/{hooks.json,config.toml}",
|
|
81
|
+
".github/hooks/**",
|
|
82
|
+
".vscode/mcp.json",
|
|
83
|
+
".mcp.json",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
/** Test, grader and CI files, and the checkpoint's own settings: what an agent bends to pass. */
|
|
87
|
+
export const TEST_PATHS = Object.freeze([...TEST_FILES, ...GATES]);
|
|
88
|
+
|
|
89
|
+
/** @param {string[]} paths */
|
|
90
|
+
const list = (paths) => `${paths.slice(0, 6).join(", ")}${paths.length > 6 ? `, and ${paths.length - 6} more` : ""}`;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {object} input
|
|
94
|
+
* @param {import("../core/diff.mjs").Change[]} input.changes
|
|
95
|
+
* @param {import("../core/evidence.mjs").Event[]} input.events the window since the last commit
|
|
96
|
+
* @param {"agent" | "person"} input.actor
|
|
97
|
+
* @param {string} input.preset
|
|
98
|
+
* @param {Record<string, Mode>} [input.modes] per-rule overrides
|
|
99
|
+
* @param {import("./secrets.mjs").SecretScan} input.secrets
|
|
100
|
+
* @param {import("./deps.mjs").DependencyCheck} input.deps
|
|
101
|
+
* @param {string[]} input.depNames dependencies the change adds, by name
|
|
102
|
+
* @param {string[] | null} [input.testCommands] the project's test commands (null: any command counts)
|
|
103
|
+
* @param {string} [input.tree] the tree being committed: a test run counts only for it
|
|
104
|
+
* @param {string[]} [input.testScripts] manifests whose test script the change rewrites (as `testScriptChanges` reads them)
|
|
105
|
+
* @returns {Finding[]}
|
|
106
|
+
*/
|
|
107
|
+
export function evaluate(input) {
|
|
108
|
+
const modes = { ...(PRESETS[input.preset] ?? PRESETS.recommended), ...(input.modes ?? {}) };
|
|
109
|
+
const { changes, events, actor } = input;
|
|
110
|
+
const agent = actor === "agent";
|
|
111
|
+
/** @type {Finding[]} */
|
|
112
|
+
const out = [];
|
|
113
|
+
const report = (/** @type {string} */ rule, /** @type {string} */ message) => {
|
|
114
|
+
const mode = modes[rule] ?? "off";
|
|
115
|
+
if (mode !== "off") out.push({ rule, mode, message });
|
|
116
|
+
};
|
|
117
|
+
// A tests event counts only as a run of the project's own tests (./test-commands.mjs).
|
|
118
|
+
const of = (/** @type {string} */ kind) => events.filter((e) => e.kind === kind && (kind !== "tests" || (isTestCommand(e.tool, input.testCommands) && (input.tree == null || e.tree === input.tree))));
|
|
119
|
+
const live = changes.filter((c) => c.status !== "D");
|
|
120
|
+
const code = live.filter((c) => matchesAny(c.path, DEFAULTS.codePaths) && !matchesAny(c.path, DEFAULTS.codeIgnore));
|
|
121
|
+
/** A file is covered by an event of `kind` holding its current blob (and exit 0 when it has one). */
|
|
122
|
+
const covered = (/** @type {string} */ kind, /** @type {import("../core/diff.mjs").Change} */ c) =>
|
|
123
|
+
of(kind).some((e) => e.blobs?.[c.path] === c.newBlob && (e.result == null || e.result === 0));
|
|
124
|
+
|
|
125
|
+
// Deterministic: everyone.
|
|
126
|
+
if (input.secrets.status === "leaks") report("secrets-scanned", `${input.secrets.count ?? "some"} secret(s) found in this change. Remove them and rotate them.`);
|
|
127
|
+
if (input.secrets.status === "unavailable") report("secrets-scanned", `This change could not be scanned for secrets: ${input.secrets.detail ?? "gitleaks did not run"}.`);
|
|
128
|
+
if (input.deps.status === "findings") report("deps-verified", input.deps.findings.map((f) => `${f.name}@${f.version ?? "?"}: ${f.problem}`).join("; "));
|
|
129
|
+
if (input.deps.status === "unavailable") report("deps-verified", `The dependencies this change adds could not be checked: ${input.deps.detail ?? "registry or OSV unreachable"}.`);
|
|
130
|
+
if (input.deps.unchecked.length) report("deps-registry", `Not checked on a registry: ${list(input.deps.unchecked)}. Trellis cannot tell whether they exist as named or have known vulnerabilities; check them, or install them from their registry.`);
|
|
131
|
+
if (input.deps.unpinned.length) report("deps-pinned", `No exact version for ${list(input.deps.unpinned)}: their known vulnerabilities cannot be checked. Commit the lockfile, or pin them.`);
|
|
132
|
+
|
|
133
|
+
// What an agent bends to pass: existing tests, graders and CI. Adding tests is welcome.
|
|
134
|
+
if (agent) {
|
|
135
|
+
const approved = of("approval");
|
|
136
|
+
const bent = changes.filter(
|
|
137
|
+
(c) => ((c.status !== "A" && matchesAny(c.path, TEST_FILES)) || matchesAny(c.path, GATES) || (input.testScripts ?? []).includes(c.path)) && !approved.some((e) => e.blobs?.[c.path] === (c.newBlob ?? "deleted")),
|
|
138
|
+
);
|
|
139
|
+
if (bent.length) report("tests-protected", `An agent changed or deleted existing tests, CI or the checkpoint's own settings: ${list(bent.map((c) => c.path))}. If the test is wrong, stop with \`trellis escalate "<why>"\`; a person approves the exact change with \`trellis approve\`.`);
|
|
140
|
+
const lastEscalation = events.map((e) => e.kind).lastIndexOf("escalation");
|
|
141
|
+
if (lastEscalation !== -1 && !events.slice(lastEscalation + 1).some((e) => e.kind === "approval")) {
|
|
142
|
+
report("escalation", `The agent asked for a person: "${events[lastEscalation].subject ?? "no reason given"}". It lands once a person reviews it and runs \`trellis approve\`.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const untested = code.filter((c) => !covered("tests", c));
|
|
147
|
+
if (untested.length) report("tests-run", `No passing test run on the current content of ${list(untested.map((c) => c.path))}. Run them through \`trellis check tests\`.`);
|
|
148
|
+
|
|
149
|
+
const ui = live.filter((c) => matchesAny(c.path, DEFAULTS.uiPaths) && !matchesAny(c.path, DEFAULTS.uiIgnore) && !covered("browser", c));
|
|
150
|
+
if (ui.length) report("ui-verified", `No browser check of the current content of ${list(ui.map((c) => c.path))}.`);
|
|
151
|
+
|
|
152
|
+
if (agent) {
|
|
153
|
+
if (code.length > 1 && !of("graph").length) report("graph-before-edit", `${code.length} code files changed with no code-graph query in this work.`);
|
|
154
|
+
const docs = of("docs").map((e) => String(e.subject ?? "").toLowerCase());
|
|
155
|
+
const unlooked = input.depNames.filter((n) => !docs.some((s) => s.includes(n.toLowerCase())));
|
|
156
|
+
if (unlooked.length) report("docs-looked-up", `No documentation lookup for the new dependencies ${list(unlooked)}.`);
|
|
157
|
+
const schema = live.filter((c) => matchesAny(c.path, DEFAULTS.schemaPaths));
|
|
158
|
+
if (schema.length && !of("schema").length) report("schema-before-change", `Schema files changed (${list(schema.map((c) => c.path))}) with no schema lookup.`);
|
|
159
|
+
if (!of("review").length && live.length) report("review", "No independent review of this change.");
|
|
160
|
+
if (live.length > 5 && !of("plan").length) report("plan-before-change", `${live.length} files changed with no plan recorded.`);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// The secret scan of a change: what is about to be committed, or one commit's own change. Trellis
|
|
2
|
+
// runs gitleaks itself; what the agent ran proves nothing.
|
|
3
|
+
//
|
|
4
|
+
// What is scanned is the content of each changed file, as git stores it, written at its own path
|
|
5
|
+
// into a folder of Trellis's own: never diff text, which the change can shape (a `.gitattributes`
|
|
6
|
+
// line `*.ini -diff` turns a file's diff into "Binary files differ"). UTF-16 text is also read as
|
|
7
|
+
// text, gzip content unpacked. The rules are gitleaks's defaults without their global path
|
|
8
|
+
// allowlist (src/verify/gitleaks-default.toml: gitleaks skips *.pdf, package-lock.json and
|
|
9
|
+
// node_modules/ by path, and a pull request chooses its own file names), and the team's own
|
|
10
|
+
// .gitleaks.toml and .gitleaksignore as committed before the change (HEAD for a commit, the base
|
|
11
|
+
// in CI), applied by gitleaks itself: every allowlist form it reads, as it reads it. An allowlist
|
|
12
|
+
// the change itself writes is scanned like any other file and never loaded, and an inline
|
|
13
|
+
// `gitleaks:allow` never counts.
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { gunzipSync } from "node:zlib";
|
|
20
|
+
import { commitDiff, stagedDiff } from "../core/diff.mjs";
|
|
21
|
+
|
|
22
|
+
/** @typedef {{ status: "clean" | "leaks" | "unavailable", count?: number, detail?: string }} SecretScan */
|
|
23
|
+
|
|
24
|
+
/** gitleaks's default rules, without the global path allowlist. */
|
|
25
|
+
const DEFAULT_RULES = fileURLToPath(new URL("./gitleaks-default.toml", import.meta.url));
|
|
26
|
+
|
|
27
|
+
/** A file gitleaks would load as its own settings if it found it in the folder it scans. */
|
|
28
|
+
const SETTINGS = /^\.gitleaks/;
|
|
29
|
+
|
|
30
|
+
/** A file as it stands at `rev`, or "" when it is not there. @param {string} root @param {string} rev @param {string} path */
|
|
31
|
+
const fileAt = (root, rev, path) => {
|
|
32
|
+
const r = spawnSync("git", ["show", `${rev}:${path}`], { cwd: root, encoding: "utf8", windowsHide: true });
|
|
33
|
+
return r.status === 0 ? r.stdout : "";
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a blob also holds as text a scanner reads: UTF-16 decoded, gzip unpacked.
|
|
38
|
+
* @param {Buffer} bytes
|
|
39
|
+
* @returns {Buffer[]}
|
|
40
|
+
*/
|
|
41
|
+
function decoded(bytes) {
|
|
42
|
+
const out = [];
|
|
43
|
+
if (bytes[0] === 0xff && bytes[1] === 0xfe) out.push(Buffer.from(bytes.subarray(2).toString("utf16le"), "utf8"));
|
|
44
|
+
if (bytes[0] === 0xfe && bytes[1] === 0xff) out.push(Buffer.from(Buffer.from(bytes.subarray(2)).swap16().toString("utf16le"), "utf8"));
|
|
45
|
+
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
|
46
|
+
try {
|
|
47
|
+
const inner = gunzipSync(bytes, { maxOutputLength: 256 * 1024 * 1024 });
|
|
48
|
+
out.push(inner, ...decoded(inner));
|
|
49
|
+
} catch {
|
|
50
|
+
/* not gzip after all */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A path as a TOML basic string. @param {string} path */
|
|
57
|
+
const tomlPath = (path) => JSON.stringify(path.replace(/\\/g, "/"));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The team's config made to run from Trellis's folder: `[extend] useDefault = true` points at
|
|
61
|
+
* gitleaks's defaults without their path allowlist, and `[extend] path = "..."` at the file it
|
|
62
|
+
* names, as committed at `rev` (followed the same way, three deep). The rest is left as written.
|
|
63
|
+
* @param {string} root @param {string} rev @param {string} text @param {string} work @param {number} depth
|
|
64
|
+
*/
|
|
65
|
+
function resolveConfig(root, rev, text, work, depth = 0) {
|
|
66
|
+
const at = text.search(/^\s*\[extend\]\s*(#.*)?$/m);
|
|
67
|
+
if (at === -1) return text;
|
|
68
|
+
const rest = text.slice(at);
|
|
69
|
+
const next = rest.slice(1).search(/^\s*\[/m);
|
|
70
|
+
const section = next === -1 ? rest : rest.slice(0, next + 1);
|
|
71
|
+
let changed = section.replace(/^(\s*)useDefault\s*=\s*true\b.*$/m, (_, indent) => `${indent}path = ${tomlPath(DEFAULT_RULES)}`);
|
|
72
|
+
changed = changed.replace(/^(\s*)path\s*=\s*(["'])(.+?)\2/m, (whole, indent, _q, target) => {
|
|
73
|
+
if (target === DEFAULT_RULES.replace(/\\/g, "/") || depth >= 3) return whole;
|
|
74
|
+
const file = join(work, `extended-${depth}.toml`);
|
|
75
|
+
writeFileSync(file, resolveConfig(root, rev, fileAt(root, rev, target.replace(/^\.\//, "")), work, depth + 1));
|
|
76
|
+
return `${indent}path = ${tomlPath(file)}`;
|
|
77
|
+
});
|
|
78
|
+
return text.slice(0, at) + changed + text.slice(at + section.length);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Scans the new content of `changes` with the rules committed at `rulesAt`.
|
|
83
|
+
* @param {string} root
|
|
84
|
+
* @param {import("../core/diff.mjs").Change[]} changes
|
|
85
|
+
* @param {string | null} rulesAt the revision whose .gitleaks.toml and .gitleaksignore apply; null: none yet
|
|
86
|
+
* @param {{ gitleaks?: string }} opts
|
|
87
|
+
* @returns {SecretScan}
|
|
88
|
+
*/
|
|
89
|
+
function scanContent(root, changes, rulesAt, opts) {
|
|
90
|
+
const bin = opts.gitleaks ?? "gitleaks";
|
|
91
|
+
const work = mkdtempSync(join(tmpdir(), "trellis-gitleaks-"));
|
|
92
|
+
try {
|
|
93
|
+
const src = join(work, "src");
|
|
94
|
+
mkdirSync(src);
|
|
95
|
+
for (const c of changes) {
|
|
96
|
+
if (!c.newBlob) continue;
|
|
97
|
+
const blob = spawnSync("git", ["cat-file", "blob", c.newBlob], { cwd: root, maxBuffer: 256 * 1024 * 1024, windowsHide: true });
|
|
98
|
+
if (blob.status !== 0) continue; // a submodule's commit, not a file
|
|
99
|
+
const parts = c.path.split("/");
|
|
100
|
+
const name = parts.pop() ?? "";
|
|
101
|
+
const file = join(src, ...parts, SETTINGS.test(name) ? `${name}.scanned` : name);
|
|
102
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
103
|
+
writeFileSync(file, blob.stdout);
|
|
104
|
+
// Beside it, under a name with none of its dots: gitleaks takes "x.gz.anything" for an archive.
|
|
105
|
+
decoded(blob.stdout).forEach((bytes, i) => writeFileSync(join(dirname(file), `${name.replace(/\./g, "_")}_decoded_${i + 1}.txt`), bytes));
|
|
106
|
+
}
|
|
107
|
+
const team = rulesAt ? fileAt(root, rulesAt, ".gitleaks.toml") : "";
|
|
108
|
+
const config = join(work, "gitleaks.toml");
|
|
109
|
+
if (team.trim()) writeFileSync(config, resolveConfig(root, /** @type {string} */ (rulesAt), team, work));
|
|
110
|
+
else copyFileSync(DEFAULT_RULES, config);
|
|
111
|
+
writeFileSync(join(work, "ignore"), (rulesAt && fileAt(root, rulesAt, ".gitleaksignore")) || "");
|
|
112
|
+
const args = ["dir", ".", "--redact", "--no-banner", "--exit-code", "1", "--ignore-gitleaks-allow", "--config", config, "--gitleaks-ignore-path", join(work, "ignore")];
|
|
113
|
+
const { GITLEAKS_CONFIG: _, GITLEAKS_CONFIG_TOML: __, ...env } = process.env;
|
|
114
|
+
const r = spawnSync(bin, args, { cwd: src, encoding: "utf8", timeout: 120_000, windowsHide: true, env });
|
|
115
|
+
if (r.error) return { status: "unavailable", detail: `gitleaks could not run (${r.error.message})` };
|
|
116
|
+
if (r.status === 0) return { status: "clean" };
|
|
117
|
+
const found = `${r.stdout}${r.stderr}`.match(/leaks found:\s*(\d+)/i);
|
|
118
|
+
if (r.status === 1 && found) return { status: "leaks", count: Number(found[1]) };
|
|
119
|
+
return { status: "unavailable", detail: `gitleaks did not complete (exit ${r.status ?? "killed"}): ${`${r.stderr}`.trim().split(/\r?\n/).pop() ?? ""}` };
|
|
120
|
+
} finally {
|
|
121
|
+
rmSync(work, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Any set of changes (CI's net change of a pull request), with the rules committed at `rulesAt`.
|
|
127
|
+
* @param {string} root
|
|
128
|
+
* @param {import("../core/diff.mjs").Change[]} changes
|
|
129
|
+
* @param {string} rulesAt
|
|
130
|
+
* @param {{ gitleaks?: string }} [opts]
|
|
131
|
+
* @returns {SecretScan}
|
|
132
|
+
*/
|
|
133
|
+
export const scanChanges = (root, changes, rulesAt, opts = {}) => scanContent(root, changes, rulesAt, opts);
|
|
134
|
+
|
|
135
|
+
/** HEAD, or null before the first commit. @param {string} root */
|
|
136
|
+
const head = (root) => {
|
|
137
|
+
const r = spawnSync("git", ["rev-parse", "-q", "--verify", "HEAD^{commit}"], { cwd: root, encoding: "utf8", windowsHide: true });
|
|
138
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What is about to be committed, with the allowlist of the last commit.
|
|
143
|
+
* @param {string} root
|
|
144
|
+
* @param {{ gitleaks?: string }} [opts]
|
|
145
|
+
* @returns {SecretScan}
|
|
146
|
+
*/
|
|
147
|
+
export function scanStaged(root, opts = {}) {
|
|
148
|
+
return scanContent(root, stagedDiff(root), head(root), opts);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* One commit's own change (a merge's own part: what each side brought is its own commits'),
|
|
153
|
+
* wherever it sits (a branch, a tag, nothing). The allowlist is the base's in CI, else the
|
|
154
|
+
* commit's first parent's.
|
|
155
|
+
* @param {string} root
|
|
156
|
+
* @param {string} commit
|
|
157
|
+
* @param {{ gitleaks?: string, base?: string }} [opts]
|
|
158
|
+
* @returns {SecretScan}
|
|
159
|
+
*/
|
|
160
|
+
export function scanCommit(root, commit, opts = {}) {
|
|
161
|
+
let changes;
|
|
162
|
+
try {
|
|
163
|
+
changes = commitDiff(root, commit);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return { status: "unavailable", detail: e instanceof Error ? e.message : String(e) };
|
|
166
|
+
}
|
|
167
|
+
const parent = spawnSync("git", ["rev-parse", "-q", "--verify", `${commit}^1`], { cwd: root, encoding: "utf8", windowsHide: true });
|
|
168
|
+
return scanContent(root, changes, opts.base ?? (parent.status === 0 ? parent.stdout.trim() : null), opts);
|
|
169
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Which commands are the project's tests: a run of anything else (`true`, `echo ok`) is not a
|
|
2
|
+
// test run, whatever its exit code. `verify.tests` in trellis.config.json names them; without
|
|
3
|
+
// it they are read from the project itself. CI reads them at the base, so a pull request cannot
|
|
4
|
+
// redefine what counts as its own tests.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { parseJson } from "../core/context.mjs";
|
|
7
|
+
|
|
8
|
+
/** npm's placeholder for a package with no tests. */
|
|
9
|
+
const NO_TESTS = /no test specified/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The project's test commands, or null when Trellis cannot tell (then any command counts, and
|
|
13
|
+
* `verify.tests` closes the gap).
|
|
14
|
+
* @param {(path: string) => string | null} read a file of the repository, as it stands where judged
|
|
15
|
+
* @returns {string[] | null}
|
|
16
|
+
*/
|
|
17
|
+
export function testCommands(read) {
|
|
18
|
+
const configured = parseJson(read("trellis.config.json") ?? "null")?.verify?.tests;
|
|
19
|
+
if (Array.isArray(configured) && configured.length) return configured.map(normalise);
|
|
20
|
+
const found = [];
|
|
21
|
+
const script = parseJson(read("package.json") ?? "null")?.scripts?.test;
|
|
22
|
+
if (typeof script === "string" && script.trim() && !NO_TESTS.test(script)) {
|
|
23
|
+
found.push("npm test", "npm run test", "npm t", "pnpm test", "pnpm run test", "yarn test", "yarn run test", "bun run test", normalise(script));
|
|
24
|
+
}
|
|
25
|
+
if (["pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"].some((f) => read(f) != null)) found.push("pytest", "python -m pytest", "uv run pytest");
|
|
26
|
+
if (read("go.mod") != null) found.push("go test ./...");
|
|
27
|
+
if (read("Cargo.toml") != null) found.push("cargo test");
|
|
28
|
+
return found.length ? found : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What a test run's environment may not carry: settings that change what a runner runs. */
|
|
32
|
+
const STEERS = /^(npm_config_.*|YARN_.*|PNPM_.*|BUN_.*|PYTEST_.*|PYTHONPATH|PYTHONSTARTUP|PYTHONHOME|PYTHONUSERBASE|NODE_OPTIONS|NODE_PATH|JEST_.*|VITEST.*|MOCHA_.*|CARGO_.*|RUSTC.*|RUSTDOC.*|RUSTFLAGS|GOFLAGS|GOENV|BASH_ENV|ENV|COMSPEC|PERL5OPT|RUBYOPT|JAVA_TOOL_OPTIONS|_JAVA_OPTIONS|JDK_JAVA_OPTIONS|GRADLE_OPTS|MAVEN_OPTS)$/i;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The environment a test run gets: the session's, without what steers a runner
|
|
36
|
+
* (`npm_config_script_shell=true` makes every script pass; `PYTEST_ADDOPTS=--collect-only` runs
|
|
37
|
+
* none), and with npm's user and global config files set to an empty one (`script-shell` in
|
|
38
|
+
* ~/.npmrc does the same).
|
|
39
|
+
* @param {NodeJS.ProcessEnv} env
|
|
40
|
+
* @param {string} emptyFile
|
|
41
|
+
*/
|
|
42
|
+
export function testEnv(env, emptyFile) {
|
|
43
|
+
const out = Object.fromEntries(Object.entries(env).filter(([k]) => !STEERS.test(k)));
|
|
44
|
+
// Node's `shell: true` on Windows runs %ComSpec%: the system's cmd.exe, never another program.
|
|
45
|
+
if (process.platform === "win32") out.ComSpec = `${env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows"}\\System32\\cmd.exe`;
|
|
46
|
+
return { ...out, npm_config_userconfig: emptyFile, npm_config_globalconfig: `${emptyFile}.global` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Runner settings that change what runs, even when git ignores them: a run with one present proves nothing. */
|
|
50
|
+
export const RUNNER_SETTINGS = Object.freeze(["**/conftest.py", "**/{pytest,tox,.pytest}.ini", "**/.yarnrc{,.yml}", "**/.cargo/config{,.toml}", "**/{jest,vitest,playwright,cypress,karma,vite}.config.*", "**/vitest.workspace.*", "**/.mocharc*", "**/{setupTests,jest.setup,vitest.setup}.*", "**/.npmrc"]);
|
|
51
|
+
|
|
52
|
+
/** @param {string} command */
|
|
53
|
+
export const normalise = (command) => command.trim().replace(/\s+/g, " ");
|
|
54
|
+
|
|
55
|
+
/** A script another one runs: `npm run unit`, `yarn unit`, `pnpm unit`, `bun run unit`. */
|
|
56
|
+
const CALLS = /\b(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?([\w:.-]+)/g;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The scripts that decide what `npm test` runs: the test scripts and every script they run, to
|
|
60
|
+
* any depth (`"test": "npm run unit"` makes `unit` part of the tests), and the test runner's
|
|
61
|
+
* settings package.json can hold.
|
|
62
|
+
* @param {string | null} text
|
|
63
|
+
*/
|
|
64
|
+
const testScripts = (text) => {
|
|
65
|
+
const doc = parseJson(text ?? "null") ?? {};
|
|
66
|
+
const scripts = doc.scripts ?? {};
|
|
67
|
+
const seen = new Set(Object.keys(scripts).filter((k) => /^(pre|post)?test(:|$)/.test(k)));
|
|
68
|
+
for (const name of seen) for (const [, called] of String(scripts[name] ?? "").matchAll(CALLS)) if (called in scripts) for (const k of [called, `pre${called}`, `post${called}`]) if (k in scripts) seen.add(k);
|
|
69
|
+
return JSON.stringify([[...seen].sort().map((k) => [k, scripts[k]]), doc.jest ?? null, doc.mocha ?? null, doc.ava ?? null]);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** pytest's sections of a pyproject.toml or setup.cfg: `[tool.pytest...]`, `[tool:pytest]`. @param {string | null} text */
|
|
73
|
+
const pytestSections = (text) => {
|
|
74
|
+
const out = [];
|
|
75
|
+
let on = false;
|
|
76
|
+
for (const line of (text ?? "").split(/\r?\n/)) {
|
|
77
|
+
const header = line.match(/^\s*\[([^\]]+)\]/);
|
|
78
|
+
if (header) on = /^(tool\.pytest|tool:pytest|pytest)/.test(header[1].trim());
|
|
79
|
+
if (on) out.push(line);
|
|
80
|
+
}
|
|
81
|
+
return out.join("\n");
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** @param {string} root @param {string | null} blob */
|
|
85
|
+
const blobText = (root, blob) => (blob ? spawnSync("git", ["cat-file", "blob", blob], { cwd: root, encoding: "utf8", windowsHide: true }).stdout : null);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The manifests whose test settings the change rewrites: package.json's test scripts (`"test":
|
|
89
|
+
* "true"` bends every test at once, while the file itself is no test file), pyproject.toml's and
|
|
90
|
+
* setup.cfg's pytest sections. A new manifest that brings them counts too.
|
|
91
|
+
* @param {string} root
|
|
92
|
+
* @param {import("../core/diff.mjs").Change[]} changes
|
|
93
|
+
* @returns {string[]}
|
|
94
|
+
*/
|
|
95
|
+
export const testScriptChanges = (root, changes) =>
|
|
96
|
+
changes
|
|
97
|
+
.filter((c) => {
|
|
98
|
+
if (c.status === "D") return false;
|
|
99
|
+
const read = /(^|\/)package\.json$/.test(c.path) ? testScripts : /(^|\/)(pyproject\.toml|setup\.cfg)$/.test(c.path) ? pytestSections : null;
|
|
100
|
+
return read != null && read(blobText(root, c.oldBlob)) !== read(blobText(root, c.newBlob));
|
|
101
|
+
})
|
|
102
|
+
.map((c) => c.path);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether `command` is exactly one of `accepted`. Arguments of its own are not accepted: a filter
|
|
106
|
+
* that matches nothing (`-t nomatch --passWithNoTests`) passes without running a test. A team
|
|
107
|
+
* that counts a narrower run names it in `verify.tests`.
|
|
108
|
+
* @param {string} command
|
|
109
|
+
* @param {string[] | null | undefined} accepted
|
|
110
|
+
*/
|
|
111
|
+
export function isTestCommand(command, accepted) {
|
|
112
|
+
// A line break is a second command to a shell (`go<newline>test ./...` runs the builtin test).
|
|
113
|
+
if (/[\r\n\0]/.test(command)) return false;
|
|
114
|
+
if (!accepted) return true;
|
|
115
|
+
return accepted.includes(normalise(command));
|
|
116
|
+
}
|