@praxso/attested 0.0.0-stage → 0.1.1

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/dist/index.js ADDED
@@ -0,0 +1,143 @@
1
+ import { attest, defaultAttestOptions } from './attest.js';
2
+ import { check, defaultCheckOptions } from './check.js';
3
+ import { defaultRunOptions, run } from './run.js';
4
+ import { AttestedError } from './util.js';
5
+ import { TOOL_VERSION } from './constants.js';
6
+ const USAGE = `attested ${TOOL_VERSION}
7
+
8
+ Usage:
9
+ attested run [options] -- <test command>
10
+ attested attest [options]
11
+ attested check [options]
12
+
13
+ run options:
14
+ --ctrf <path> CTRF report the test command writes (default attested/ctrf.json)
15
+ --output-dir <dir> Playwright output dir to hash (default test-results)
16
+ --config <path> test config file to record (default: playwright.config.*)
17
+ --expect-sha <sha> refuse to run unless HEAD equals <sha>
18
+ --allow-dirty allow a dirty working tree
19
+ --target-url <url> fail before tests unless <url>/healthz reports sha == --expect-sha (or HEAD)
20
+ --health-path <path> health path for --target-url (default /healthz)
21
+ --receipt <path> where to write the receipt (default attested/receipt.json)
22
+ --packet <path> where to write the failure packet on red (default attested/packet.json)
23
+
24
+ attest options:
25
+ --receipt <path>, --ctrf <path>
26
+ --from-results <dir> build and prepare the receipt from a downloaded results directory (attested/ctrf.json,
27
+ test-results/, test config), treated as data; source comes from GITHUB_SHA and the event
28
+ --out <dir> where --from-results writes ctrf.json, receipt, predicates, packet (default signed/attested)
29
+ --output-dir <dir>, --config <path>, --rerun <command> (with --from-results)
30
+
31
+ check options (posts the "attested" check run; needs GITHUB_TOKEN with checks: write):
32
+ --artifact-url <url> link to the uploaded workflow artifact
33
+ --artifact-name <name> workflow artifact name, for the paste-able verify command
34
+ --evidence-name <name> artifact holding the traces, videos and screenshots, for the paste-able download
35
+ command (default: results-<sha> when --artifact-name is attested-<sha>)
36
+ --attestation-url <url> link to the receipt attestation
37
+ --receipt <path>, --packet <path>
38
+ `;
39
+ function takeValue(args, i, flag) {
40
+ const v = args[i + 1];
41
+ if (v === undefined || v.startsWith('--'))
42
+ throw new AttestedError(`${flag} needs a value`);
43
+ return v;
44
+ }
45
+ // Like takeValue, but an empty string (an unset workflow output) means "not given".
46
+ function optValue(args, i, flag) {
47
+ const v = args[i + 1];
48
+ if (v === undefined)
49
+ throw new AttestedError(`${flag} needs a value`);
50
+ return v === '' ? null : v;
51
+ }
52
+ async function main(argv) {
53
+ const [cmd, ...rest] = argv;
54
+ if (!cmd || cmd === '--help' || cmd === '-h') {
55
+ process.stdout.write(USAGE);
56
+ return cmd ? 0 : 2;
57
+ }
58
+ if (cmd === '--version') {
59
+ process.stdout.write(TOOL_VERSION + '\n');
60
+ return 0;
61
+ }
62
+ if (cmd === 'run') {
63
+ const o = defaultRunOptions();
64
+ o.argv = argv;
65
+ for (let i = 0; i < rest.length; i++) {
66
+ const a = rest[i];
67
+ if (a === '--') {
68
+ o.command = rest.slice(i + 1);
69
+ break;
70
+ }
71
+ else if (a === '--ctrf')
72
+ o.ctrf = takeValue(rest, i++, a);
73
+ else if (a === '--output-dir')
74
+ o.outputDir = takeValue(rest, i++, a);
75
+ else if (a === '--config')
76
+ o.config = takeValue(rest, i++, a);
77
+ else if (a === '--expect-sha')
78
+ o.expectSha = takeValue(rest, i++, a);
79
+ else if (a === '--allow-dirty')
80
+ o.allowDirty = true;
81
+ else if (a === '--target-url')
82
+ o.targetUrl = takeValue(rest, i++, a);
83
+ else if (a === '--health-path')
84
+ o.healthPath = takeValue(rest, i++, a);
85
+ else if (a === '--receipt')
86
+ o.receipt = takeValue(rest, i++, a);
87
+ else if (a === '--packet')
88
+ o.packet = takeValue(rest, i++, a);
89
+ else
90
+ throw new AttestedError(`unknown option ${a}`);
91
+ }
92
+ return run(o);
93
+ }
94
+ if (cmd === 'attest') {
95
+ const o = defaultAttestOptions();
96
+ for (let i = 0; i < rest.length; i++) {
97
+ const a = rest[i];
98
+ if (a === '--receipt')
99
+ o.receipt = takeValue(rest, i++, a);
100
+ else if (a === '--ctrf')
101
+ o.ctrf = takeValue(rest, i++, a);
102
+ else if (a === '--from-results')
103
+ o.fromResults = takeValue(rest, i++, a);
104
+ else if (a === '--out')
105
+ o.out = takeValue(rest, i++, a);
106
+ else if (a === '--output-dir')
107
+ o.outputDir = takeValue(rest, i++, a);
108
+ else if (a === '--config')
109
+ o.config = takeValue(rest, i++, a);
110
+ else if (a === '--rerun')
111
+ o.rerun = takeValue(rest, i++, a);
112
+ else
113
+ throw new AttestedError(`unknown option ${a}`);
114
+ }
115
+ return attest(o);
116
+ }
117
+ if (cmd === 'check') {
118
+ const o = defaultCheckOptions();
119
+ for (let i = 0; i < rest.length; i++) {
120
+ const a = rest[i];
121
+ if (a === '--receipt')
122
+ o.receipt = takeValue(rest, i++, a);
123
+ else if (a === '--packet')
124
+ o.packet = takeValue(rest, i++, a);
125
+ else if (a === '--artifact-url')
126
+ o.artifactUrl = optValue(rest, i++, a);
127
+ else if (a === '--artifact-name')
128
+ o.artifactName = optValue(rest, i++, a);
129
+ else if (a === '--evidence-name')
130
+ o.evidenceName = optValue(rest, i++, a);
131
+ else if (a === '--attestation-url')
132
+ o.attestationUrl = optValue(rest, i++, a);
133
+ else
134
+ throw new AttestedError(`unknown option ${a}`);
135
+ }
136
+ return check(o);
137
+ }
138
+ throw new AttestedError(`unknown command ${cmd}\n${USAGE}`);
139
+ }
140
+ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
141
+ process.stderr.write(`attested: error: ${err instanceof AttestedError ? err.message : err.stack}\n`);
142
+ process.exit(err instanceof AttestedError ? 2 : 1);
143
+ });
package/dist/packet.js ADDED
@@ -0,0 +1,277 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { inflateRawSync } from 'node:zlib';
3
+ import { isAbsolute, relative, resolve } from 'node:path';
4
+ import { TOOL_NAME } from './constants.js';
5
+ import { testNameOf } from './receipt.js';
6
+ import { ANSI, clean, clip, stripUrl } from './redact.js';
7
+ import { relPath, warn } from './util.js';
8
+ export { redact, stripUrl } from './redact.js';
9
+ // The failure packet: deterministic, model-free, at most 12 KB. Written on red so an agent can fix without opening traces.
10
+ export const PACKET_MAX_BYTES = 12 * 1024;
11
+ const MAX_FRAMES = 20;
12
+ const MAX_MESSAGE = 1500;
13
+ // Bounds on every other string in the packet, so that after capPacket drops every list entry the rest always fits.
14
+ const MAX_NAME = 300;
15
+ const MAX_PATH = 300;
16
+ const MAX_FRAME = 300;
17
+ const MAX_METHOD = 16;
18
+ const MAX_RERUN = 500;
19
+ const MAX_EVIDENCE = 10;
20
+ // Trace zips come from an untrusted results directory: bound what is read and what is inflated.
21
+ export const MAX_TRACE_ZIP_BYTES = 256 * 1024 * 1024;
22
+ export const MAX_TRACE_ENTRY_BYTES = 32 * 1024 * 1024;
23
+ export const MAX_TRACE_TOTAL_BYTES = 128 * 1024 * 1024;
24
+ // --- minimal zip reader (Playwright trace.zip), stored and deflate entries only --------------
25
+ // `budget.left` is the number of inflated bytes still allowed across all traces; entries past it are skipped with a warning.
26
+ export function readZip(path, want, budget = { left: MAX_TRACE_TOTAL_BYTES }) {
27
+ const out = new Map();
28
+ let buf;
29
+ try {
30
+ if (statSync(path).size > MAX_TRACE_ZIP_BYTES) {
31
+ warn(`trace ${path} is larger than ${MAX_TRACE_ZIP_BYTES} bytes; skipped`);
32
+ return out;
33
+ }
34
+ buf = readFileSync(path);
35
+ }
36
+ catch {
37
+ return out;
38
+ }
39
+ let eocd = -1;
40
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--)
41
+ if (buf.readUInt32LE(i) === 0x06054b50) {
42
+ eocd = i;
43
+ break;
44
+ }
45
+ if (eocd < 0)
46
+ return out;
47
+ const entries = buf.readUInt16LE(eocd + 10);
48
+ let p = buf.readUInt32LE(eocd + 16);
49
+ for (let n = 0; n < entries && buf.readUInt32LE(p) === 0x02014b50; n++) {
50
+ const method = buf.readUInt16LE(p + 10);
51
+ const csize = buf.readUInt32LE(p + 20);
52
+ const nameLen = buf.readUInt16LE(p + 28);
53
+ const extraLen = buf.readUInt16LE(p + 30);
54
+ const commentLen = buf.readUInt16LE(p + 32);
55
+ const local = buf.readUInt32LE(p + 42);
56
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
57
+ p += 46 + nameLen + extraLen + commentLen;
58
+ if (!want(name))
59
+ continue;
60
+ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
61
+ const data = buf.subarray(start, start + csize);
62
+ // Once the shared budget is spent, the remaining entries are not inflated at all.
63
+ if (budget.left <= 0) {
64
+ warn(`trace ${path}: the ${MAX_TRACE_TOTAL_BYTES}-byte budget across all traces is spent; remaining entries skipped`);
65
+ break;
66
+ }
67
+ const limit = Math.min(MAX_TRACE_ENTRY_BYTES, budget.left);
68
+ try {
69
+ let text;
70
+ if (method === 0)
71
+ text = data;
72
+ else if (method === 8)
73
+ text = inflateRawSync(data, { maxOutputLength: limit + 1 });
74
+ else
75
+ continue;
76
+ if (text.length > limit)
77
+ throw new RangeError('limit');
78
+ budget.left -= text.length;
79
+ out.set(name, text.toString('utf8'));
80
+ }
81
+ catch (err) {
82
+ // Over the limit (zlib reports ERR_BUFFER_TOO_LARGE) or unreadable: skip the entry.
83
+ // A rejected entry may have been inflated up to limit + 1 bytes before it was refused: charge that work too.
84
+ budget.left = Math.max(0, budget.left - (limit + 1));
85
+ if (err instanceof RangeError || err.code === 'ERR_BUFFER_TOO_LARGE') {
86
+ warn(`trace ${path}: entry ${clean(name, MAX_PATH)} inflates past ${limit} bytes (per entry ${MAX_TRACE_ENTRY_BYTES}, all traces ${MAX_TRACE_TOTAL_BYTES}); skipped`);
87
+ }
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+ function jsonl(text) {
93
+ const out = [];
94
+ for (const line of text.split('\n')) {
95
+ if (!line.trim())
96
+ continue;
97
+ try {
98
+ out.push(JSON.parse(line));
99
+ }
100
+ catch { /* skip */ }
101
+ }
102
+ return out;
103
+ }
104
+ // Console errors/warnings and failed requests from Playwright trace zips. Order of `seq` is trace order across traces.
105
+ export function readTraces(paths) {
106
+ const cons = new Map();
107
+ const net = new Map();
108
+ let seq = 0;
109
+ const budget = { left: MAX_TRACE_TOTAL_BYTES };
110
+ for (const path of paths) {
111
+ if (budget.left <= 0)
112
+ break;
113
+ let files;
114
+ // Trace zips can come from an untrusted results artifact: a malformed one is skipped, never fatal.
115
+ try {
116
+ files = readZip(path, (n) => n.endsWith('.trace') || n.endsWith('.network'), budget);
117
+ }
118
+ catch {
119
+ continue;
120
+ }
121
+ for (const name of [...files.keys()].sort()) {
122
+ for (const ev of jsonl(files.get(name))) {
123
+ seq++;
124
+ if (ev.type === 'console' && (ev.messageType === 'error' || ev.messageType === 'warning')) {
125
+ const level = ev.messageType;
126
+ const message = clean(ev.text ?? '', 300);
127
+ const key = `${level}\u0000${message}`;
128
+ const e = cons.get(key) ?? { level, message, count: 0, lastSeen: 0 };
129
+ e.count++;
130
+ e.lastSeen = seq;
131
+ cons.set(key, e);
132
+ }
133
+ else if (ev.type === 'event' && ev.method === 'pageError') {
134
+ const err = ev.params?.error?.error ?? ev.params?.error ?? {};
135
+ const message = clean(`${err.name ?? 'Error'}: ${err.message ?? ''}`, 300);
136
+ const key = `error\u0000${message}`;
137
+ const e = cons.get(key) ?? { level: 'error', message, count: 0, lastSeen: 0 };
138
+ e.count++;
139
+ e.lastSeen = seq;
140
+ cons.set(key, e);
141
+ }
142
+ else if (ev.type === 'resource-snapshot' && ev.snapshot?.request) {
143
+ const raw = Number(ev.snapshot.response?.status ?? -1);
144
+ const status = Number.isInteger(raw) && raw >= -1 && raw < 1000 ? raw : -1;
145
+ const failed = status >= 400 || status <= 0 || Boolean(ev.snapshot.response?._failureText);
146
+ if (!failed)
147
+ continue;
148
+ let path = String(ev.snapshot.request.url ?? '');
149
+ try {
150
+ path = new URL(path).pathname;
151
+ }
152
+ catch {
153
+ path = stripUrl(path);
154
+ }
155
+ const method = clean(ev.snapshot.request.method ?? 'GET', MAX_METHOD);
156
+ path = clean(path, MAX_PATH);
157
+ const key = `${method} ${path} ${status}`;
158
+ const e = net.get(key) ?? { method, path, status, count: 0, lastSeen: 0 };
159
+ e.count++;
160
+ e.lastSeen = seq;
161
+ net.set(key, e);
162
+ }
163
+ }
164
+ }
165
+ }
166
+ return { console: [...cons.values()], network: [...net.values()] };
167
+ }
168
+ // --- packet ------------------------------------------------------------------------------------
169
+ function shellQuote(a) {
170
+ return /^[A-Za-z0-9_\/.,:=@%+-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`;
171
+ }
172
+ export function rerunCommand(argv) {
173
+ return clean(['attested', ...argv].map(shellQuote).join(' '), MAX_RERUN);
174
+ }
175
+ function lineOf(t, file, stack) {
176
+ if (typeof t.line === 'number')
177
+ return Number.isInteger(t.line) && t.line >= 0 && t.line < 1e9 ? t.line : null;
178
+ if (!file)
179
+ return null;
180
+ for (const f of stack) {
181
+ const m = f.includes(file) ? /:(\d{1,9}):\d+\)?$/.exec(f) : null;
182
+ if (m)
183
+ return Number(m[1]);
184
+ }
185
+ return null;
186
+ }
187
+ // Attachments with a string path; anything else in an untrusted CTRF report is ignored.
188
+ function attachmentsOf(t) {
189
+ return Array.isArray(t.attachments) ? t.attachments.filter((a) => typeof a?.path === 'string' && a.path !== '') : [];
190
+ }
191
+ export function buildPacket(ctrf, artifacts, cwd, rerun) {
192
+ const tests = (ctrf.results.tests ?? []).filter((t) => t.status === 'failed');
193
+ const byPath = new Map(artifacts.map((a) => [a.path, a]));
194
+ const rel = (p) => relPath(cwd, isAbsolute(p) ? p : resolve(cwd, p));
195
+ // Attachment paths are absolute on the machine that ran the tests. When the packet is built elsewhere (the sign job,
196
+ // from a downloaded results directory), match them to artifacts by suffix and infer that machine's project root.
197
+ const lookup = (p) => byPath.get(rel(p)) ?? artifacts.find((a) => p.split('\\').join('/').endsWith('/' + a.path));
198
+ let srcRoot = cwd;
199
+ find: for (const t of tests) {
200
+ for (const a of attachmentsOf(t)) {
201
+ if (!byPath.has(rel(a.path))) {
202
+ const art = lookup(a.path);
203
+ if (art)
204
+ srcRoot = a.path.slice(0, a.path.length - art.path.length - 1);
205
+ }
206
+ break find;
207
+ }
208
+ }
209
+ const traces = [];
210
+ const groups = new Map();
211
+ for (const t of tests) {
212
+ const raw = String(t.trace ?? t.message ?? '').replace(ANSI, '');
213
+ const stack = raw.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('at ')).slice(0, MAX_FRAMES)
214
+ .map((f) => clean(f.split(srcRoot + '/').join(''), MAX_FRAME));
215
+ const message = clean(t.message ?? '', MAX_MESSAGE, true);
216
+ const file = typeof t.filePath === 'string' && t.filePath ? clean(relative(srcRoot, t.filePath).split('\\').join('/'), MAX_PATH) : null;
217
+ const evidence = [];
218
+ for (const a of attachmentsOf(t)) {
219
+ const art = lookup(a.path);
220
+ if (!art || !['trace', 'video', 'screenshot'].includes(art.kind))
221
+ continue;
222
+ if (evidence.length < MAX_EVIDENCE)
223
+ evidence.push({ kind: art.kind, path: clean(art.path, MAX_PATH), sha256: art.sha256 });
224
+ if (art.kind === 'trace' && existsSync(resolve(cwd, art.path)))
225
+ traces.push(resolve(cwd, art.path));
226
+ }
227
+ const name = clean(testNameOf(t), MAX_NAME);
228
+ // Identical failures (same file, line, and message) collapse into one entry with a count.
229
+ const key = `${file}\u0000${lineOf(t, file, stack)}\u0000${message}`;
230
+ const g = groups.get(key);
231
+ if (g) {
232
+ g.count++;
233
+ continue;
234
+ }
235
+ groups.set(key, { name, file, line: lineOf(t, file, stack), message, stack, count: 1, evidence });
236
+ }
237
+ const { console: cons, network } = readTraces([...new Set(traces)]);
238
+ const byRecent = (xs) => xs.sort((a, b) => b.lastSeen - a.lastSeen).map(({ lastSeen, ...rest }) => rest);
239
+ const packet = {
240
+ tool: TOOL_NAME,
241
+ result: 'FAILED',
242
+ failingTests: [...groups.values()].sort((a, b) => a.name.localeCompare(b.name)),
243
+ console: byRecent(cons),
244
+ network: byRecent(network),
245
+ rerun: clean(rerun, MAX_RERUN),
246
+ truncated: false,
247
+ };
248
+ return capPacket(packet);
249
+ }
250
+ export function serializePacket(p) {
251
+ return JSON.stringify(p, null, 2) + '\n';
252
+ }
253
+ // Enforce the 12 KB cap: cut stacks to 5 frames, then drop from the end of the longest list
254
+ // (tests are alphabetical, console and network most recent first), recording what was cut.
255
+ export function capPacket(p) {
256
+ if (Buffer.byteLength(serializePacket(p)) <= PACKET_MAX_BYTES)
257
+ return p;
258
+ const cut = { tests: 0, console: 0, network: 0, frames: false };
259
+ p.truncated = cut;
260
+ for (const t of p.failingTests)
261
+ if (t.stack.length > 5) {
262
+ t.stack = t.stack.slice(0, 5);
263
+ cut.frames = true;
264
+ }
265
+ while (Buffer.byteLength(serializePacket(p)) > PACKET_MAX_BYTES) {
266
+ const lists = [['tests', p.failingTests], ['console', p.console], ['network', p.network]];
267
+ const [name, list] = [...lists].sort((a, b) => b[1].length - a[1].length)[0];
268
+ if (list.length === 0)
269
+ break;
270
+ list.pop();
271
+ cut[name]++;
272
+ }
273
+ // Every remaining field is bounded above, so this only trims the rerun command if a caller passed an unbounded one.
274
+ if (Buffer.byteLength(serializePacket(p)) > PACKET_MAX_BYTES)
275
+ p.rerun = clip(p.rerun, 200);
276
+ return p;
277
+ }
@@ -0,0 +1,61 @@
1
+ import { PREDICATE_TYPE, STATEMENT_TYPE, SUBJECT_NAME, TOOL_NAME, TOOL_VERSION } from './constants.js';
2
+ import { clean } from './redact.js';
3
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
4
+ // Why `ctrf` cannot be judged, or null when it can. `results` must be an object with a `tests` array whose entries all
5
+ // carry a string name and status, and `summary`, when present, must be an object. An empty `tests` array is valid.
6
+ export function validateCtrf(ctrf) {
7
+ if (!isObject(ctrf) || !isObject(ctrf.results))
8
+ return 'missing "results"';
9
+ const { tests, summary } = ctrf.results;
10
+ if (!Array.isArray(tests))
11
+ return '"results.tests" is not an array';
12
+ const bad = tests.findIndex((t) => !isObject(t) || typeof t.name !== 'string' || typeof t.status !== 'string');
13
+ if (bad >= 0)
14
+ return `"results.tests[${bad}]" has no string name and status`;
15
+ if (summary !== undefined && !isObject(summary))
16
+ return '"results.summary" is not an object';
17
+ return null;
18
+ }
19
+ export function testNameOf(t) {
20
+ const prefix = t.suite ?? t.filePath;
21
+ return prefix ? `${prefix} > ${t.name}` : t.name;
22
+ }
23
+ // Test names in the receipt are signed and public with the repository: redacted and bounded like the packet's.
24
+ const MAX_TEST_NAME = 300;
25
+ const nameOf = (t) => clean(testNameOf(t), MAX_TEST_NAME);
26
+ export function testResultFromCtrf(ctrf, configuration, url) {
27
+ const tests = Array.isArray(ctrf.results.tests) ? ctrf.results.tests.filter((t) => t && typeof t === 'object') : [];
28
+ // A passed test that CTRF marks flaky (passed on a retry) is warned, not passed.
29
+ const flaky = (t) => t.status === 'passed' && t.flaky === true;
30
+ const passedTests = tests.filter((t) => t.status === 'passed' && !flaky(t)).map(nameOf);
31
+ // CTRF's statuses are passed, failed, skipped, pending and other. Anything else (a reporter's own "timedOut" or
32
+ // "interrupted", or garbage) is counted as a failure rather than waved through as a warning.
33
+ const known = new Set(['passed', 'failed', 'skipped', 'pending', 'other']);
34
+ const failedTests = tests.filter((t) => t.status === 'failed' || !known.has(t.status)).map(nameOf);
35
+ // CTRF has no "warned" status; flaky/other/skipped/pending land here so nothing is silently dropped.
36
+ const warnedTests = tests.filter((t) => known.has(t.status) && t.status !== 'failed' && (t.status !== 'passed' || flaky(t)))
37
+ .map((t) => `${nameOf(t)} [${flaky(t) ? 'flaky' : clean(t.status, 20)}]`);
38
+ const summary = ctrf.results.summary ?? {};
39
+ const summaryFlaky = typeof summary.flaky === 'number' && summary.flaky > 0;
40
+ // FAILED if either the summary or any individual test reports a failure. A summary failed count that is present but
41
+ // not the number 0 counts as a failure, so a malformed summary cannot hide one.
42
+ // A run in which no test passed (an empty report, a crashed reporter, everything skipped) is not evidence that
43
+ // anything works, so it is FAILED, not PASSED or WARNED.
44
+ const failed = failedTests.length > 0 || (summary.failed !== undefined && summary.failed !== 0) || tests.every((t) => t.status !== 'passed');
45
+ const result = failed ? 'FAILED' : warnedTests.length > 0 || summaryFlaky ? 'WARNED' : 'PASSED';
46
+ const tr = { result, configuration, passedTests, warnedTests, failedTests };
47
+ if (url)
48
+ tr.url = url;
49
+ return tr;
50
+ }
51
+ export function buildReceipt(subjectSha256, predicate) {
52
+ return {
53
+ _type: STATEMENT_TYPE,
54
+ subject: [{ name: SUBJECT_NAME, digest: { sha256: subjectSha256 } }],
55
+ predicateType: PREDICATE_TYPE,
56
+ predicate,
57
+ };
58
+ }
59
+ export function toolInfo() {
60
+ return { name: TOOL_NAME, version: TOOL_VERSION };
61
+ }
package/dist/redact.js ADDED
@@ -0,0 +1,48 @@
1
+ // Redaction and bounds for every string that leaves the machine in a receipt, packet, or check summary.
2
+ // Kept separate from packet.ts so receipt.ts can use it without an import cycle.
3
+ export const ANSI = /\u001b\[[0-9;?]*[ -\/]*[@-~]/g;
4
+ const SECRET_PATTERNS = [
5
+ // PEM / OpenSSH private keys, including a block cut off before its END line.
6
+ [/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g, '[REDACTED PRIVATE KEY]'],
7
+ // Credentials in any URL's userinfo (postgres://u:pw@, redis://:pw@, https://u:pw@), before the email pattern sees them.
8
+ [/\b([a-z][a-z0-9+.-]*:\/\/)[^\s\/@"'<>]*:[^\s\/@"'<>]*@/gi, '$1[REDACTED]@'],
9
+ // Webhook URLs whose path is the secret.
10
+ [/\bhttps?:\/\/(?:hooks\.slack\.com\/services|(?:discord|discordapp)\.com\/api\/webhooks)\/[^\s"'<>)\]]+/gi, '[REDACTED WEBHOOK]'],
11
+ [/\b(set-cookie|cookie)(\s*:\s*)[^\r\n]+/gi, '$1$2[REDACTED]'],
12
+ [/\b(bearer|basic|token)\s+[A-Za-z0-9\-._~+\/]{8,}=*/gi, '$1 [REDACTED]'],
13
+ [/\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9_-]{16,}|[sr]k_(?:live|test)_[A-Za-z0-9]{10,}|(?:AKIA|ASIA)[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|npm_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9_-]{20,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})\b/g, '[REDACTED]'],
14
+ [/\b([A-Za-z0-9_-]*(?:key|token|secret|password|passwd|pwd|auth|session|signature|credential)[A-Za-z0-9_-]*)(["']?\s*[=:]\s*["']?)(?!(?:bearer|basic|token)\s|\[REDACTED)[^\s"'&,;)}\]]+/gi, '$1$2[REDACTED]'],
15
+ [/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '[EMAIL]'],
16
+ ];
17
+ // Control characters (newlines included) would let an untrusted string start a new log line, and with it a GitHub
18
+ // Actions workflow command such as ::add-mask:: or ::warning::. Every bounded string is one line.
19
+ const CONTROL = /[\u0000-\u001f\u007f]+/g;
20
+ // The one exception: a failure message keeps its newlines (an expected/received diff), because it is only ever
21
+ // serialized as JSON, never printed to a log.
22
+ const CONTROL_EXCEPT_NEWLINE = /[\u0000-\u0009\u000b-\u001f\u007f]+/g;
23
+ const URL_RE = /\b(https?|wss?):\/\/[^\s"'<>)\]]+/gi;
24
+ // Drop the query string and fragment of a URL.
25
+ export function stripUrl(u) {
26
+ return u.replace(/[?#].*$/, '');
27
+ }
28
+ // Strip ANSI, URL queries and fragments, and anything shaped like a secret or an email.
29
+ export function redact(text) {
30
+ let out = text.replace(ANSI, '');
31
+ // Private keys span lines, so they go before URL stripping and the line-bounded patterns.
32
+ out = out.replace(SECRET_PATTERNS[0][0], SECRET_PATTERNS[0][1]).replace(URL_RE, (u) => stripUrl(u));
33
+ for (const [re, rep] of SECRET_PATTERNS.slice(1))
34
+ out = out.replace(re, rep);
35
+ return out;
36
+ }
37
+ export function clip(text, max) {
38
+ return text.length > max ? `${text.slice(0, max)}… [${text.length - max} chars cut]` : text;
39
+ }
40
+ // Redact, then clip: the bounded form of an untrusted string. A huge input is cut to a little over `max` before the
41
+ // patterns run, so their cost stays bounded; everything past `max` is dropped either way.
42
+ export function clean(text, max, keepNewlines = false) {
43
+ const s = String(text ?? '');
44
+ const line = (t) => t.replace(keepNewlines ? CONTROL_EXCEPT_NEWLINE : CONTROL, ' ');
45
+ if (s.length <= max + 256)
46
+ return clip(line(redact(s)), max);
47
+ return `${line(redact(s.slice(0, max + 256))).slice(0, max)}… [${s.length - max} chars cut]`;
48
+ }
package/dist/run.js ADDED
@@ -0,0 +1,110 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdirSync, rmSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { DEFAULTS } from './constants.js';
5
+ import { assemble, writeReceipt } from './assemble.js';
6
+ import { clean, stripUrl } from './redact.js';
7
+ import { rerunCommand } from './packet.js';
8
+ import { AttestedError, gitDirty, gitHead, gitRef, info, readEvent, relPath } from './util.js';
9
+ export function defaultRunOptions() {
10
+ return {
11
+ command: [],
12
+ ctrf: DEFAULTS.ctrf,
13
+ outputDir: DEFAULTS.outputDir,
14
+ receipt: DEFAULTS.receipt,
15
+ predicate: DEFAULTS.predicate,
16
+ testResultPredicate: DEFAULTS.testResultPredicate,
17
+ config: null,
18
+ expectSha: null,
19
+ allowDirty: false,
20
+ targetUrl: null,
21
+ healthPath: DEFAULTS.healthPath,
22
+ packet: DEFAULTS.packet,
23
+ argv: [],
24
+ };
25
+ }
26
+ // A target URL as written into the receipt: no credentials, query, or fragment; redacted and bounded.
27
+ export function publicUrl(u) {
28
+ return clean(stripUrl(u).replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/i, '$1'), 300);
29
+ }
30
+ // Probe <url><healthPath> and require a `sha` field equal to expectedSha. Any failure is fatal:
31
+ // a receipt must not claim tests ran against a deployment it cannot identify.
32
+ async function probeTarget(url, healthPath, expectedSha) {
33
+ // A path only: `new URL('https://other.host/x', url)` would send the probe to another host.
34
+ if (!healthPath.startsWith('/') || healthPath.startsWith('//'))
35
+ throw new AttestedError(`attested: --health-path must be a path starting with "/", got ${clean(healthPath, 200)}`);
36
+ const target = new URL(healthPath, url).toString();
37
+ let res;
38
+ let text;
39
+ try {
40
+ res = await fetch(target, { signal: AbortSignal.timeout(5000) });
41
+ text = await res.text();
42
+ }
43
+ catch (err) {
44
+ throw new AttestedError(`attested run: target ${target} is unreachable (${err.message}); refusing to run tests`);
45
+ }
46
+ if (!res.ok)
47
+ throw new AttestedError(`attested run: target ${target} returned HTTP ${res.status}; refusing to run tests`);
48
+ let body = text;
49
+ try {
50
+ body = JSON.parse(text);
51
+ }
52
+ catch { /* keep text */ }
53
+ const buildSha = typeof body === 'object' && body !== null && typeof body.sha === 'string'
54
+ ? (body.sha) : null;
55
+ if (!buildSha)
56
+ throw new AttestedError(`attested run: target ${target} did not report a build sha (expected JSON with a "sha" field); refusing to run tests`);
57
+ if (buildSha !== expectedSha) {
58
+ throw new AttestedError(`attested run: target ${target} is built from ${buildSha}, expected ${expectedSha}; refusing to run tests`);
59
+ }
60
+ // Allowlisted fields only: the health body can carry anything, so it is never copied into the receipt.
61
+ return { buildSha, url: publicUrl(url), health: { probed: publicUrl(target), status: res.status } };
62
+ }
63
+ export async function run(opts) {
64
+ const cwd = process.cwd();
65
+ if (opts.command.length === 0)
66
+ throw new AttestedError('attested run: no test command given (use `attested run -- <command>`)');
67
+ // Fail-closed checks happen before any test runs.
68
+ const head = gitHead(cwd);
69
+ if (opts.expectSha) {
70
+ if (!head)
71
+ throw new AttestedError('attested run: --expect-sha given but HEAD could not be resolved');
72
+ if (head !== opts.expectSha)
73
+ throw new AttestedError(`attested run: HEAD is ${head}, expected ${opts.expectSha}; refusing to run tests`);
74
+ }
75
+ const dirty = gitDirty(cwd);
76
+ if (dirty && !opts.allowDirty) {
77
+ throw new AttestedError('attested run: working tree is dirty; commit or pass --allow-dirty');
78
+ }
79
+ // --target-url is fail-closed: the deployment must report the sha we are about to test.
80
+ let target = { buildSha: null, url: null };
81
+ if (opts.targetUrl) {
82
+ const expected = opts.expectSha ?? head;
83
+ if (!expected)
84
+ throw new AttestedError('attested run: --target-url needs --expect-sha or a git HEAD to compare the build sha against');
85
+ target = await probeTarget(opts.targetUrl, opts.healthPath, expected);
86
+ info(`target ${opts.targetUrl} reports build ${target.buildSha}`);
87
+ }
88
+ const ctrfPath = resolve(cwd, opts.ctrf);
89
+ rmSync(ctrfPath, { force: true });
90
+ mkdirSync(dirname(ctrfPath), { recursive: true });
91
+ info(`running: ${opts.command.join(' ')}`);
92
+ const started = new Date().toISOString();
93
+ const child = spawnSync(opts.command[0], opts.command.slice(1), { stdio: 'inherit', cwd, env: process.env });
94
+ const exitCode = child.status ?? 1;
95
+ info(`test command exited with ${exitCode}`);
96
+ const a = assemble(cwd, { ctrf: opts.ctrf, outputDir: opts.outputDir, config: opts.config, rerun: rerunCommand(opts.argv), who: 'attested run' });
97
+ const packet = resolve(cwd, opts.packet);
98
+ writeReceipt(a, {
99
+ receipt: resolve(cwd, opts.receipt),
100
+ predicate: resolve(cwd, opts.predicate),
101
+ testResultPredicate: resolve(cwd, opts.testResultPredicate),
102
+ packet,
103
+ packetArtifactPath: relPath(cwd, packet),
104
+ }, {
105
+ source: { commit: head, headCommit: readEvent()?.pull_request?.head?.sha ?? null, dirty, ref: gitRef(cwd) },
106
+ target,
107
+ createdAt: started,
108
+ });
109
+ return exitCode;
110
+ }