@praxso/attested 0.1.0 → 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/assemble.js +65 -20
- package/dist/attest.js +17 -5
- package/dist/check.js +36 -3
- package/dist/constants.js +2 -1
- package/dist/index.js +4 -0
- package/dist/packet.js +74 -51
- package/dist/receipt.js +37 -6
- package/dist/redact.js +48 -0
- package/dist/run.js +10 -1
- package/dist/util.js +122 -5
- package/package.json +3 -2
package/dist/assemble.js
CHANGED
|
@@ -1,34 +1,79 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, rmSync, statSync } from 'node:fs';
|
|
2
3
|
import { dirname, resolve } from 'node:path';
|
|
3
4
|
import { buildPacket, serializePacket } from './packet.js';
|
|
4
|
-
import { buildReceipt, testResultFromCtrf, toolInfo } from './receipt.js';
|
|
5
|
-
import {
|
|
5
|
+
import { buildReceipt, testResultFromCtrf, toolInfo, validateCtrf } from './receipt.js';
|
|
6
|
+
import { clean } from './redact.js';
|
|
7
|
+
import { AttestedError, checkInside, classify, info, readInside, relPath, runUrl, sha256File, walk, writeOutput } from './util.js';
|
|
8
|
+
// Largest ctrf.json that is parsed. CTRF for tens of thousands of tests is a few MB.
|
|
9
|
+
export const MAX_CTRF_BYTES = 32 * 1024 * 1024;
|
|
10
|
+
const SUMMARY_FIELDS = ['tests', 'passed', 'failed', 'skipped', 'pending', 'other', 'flaky', 'start', 'stop', 'duration'];
|
|
11
|
+
// The CTRF metadata copied into the receipt: allowlisted fields only, strings redacted and bounded, counts numeric.
|
|
12
|
+
// The ctrf.json subject itself is never rewritten; only these copies are.
|
|
13
|
+
export function ctrfMeta(ctrf) {
|
|
14
|
+
const str = (v, max) => (typeof v === 'string' ? clean(v, max) : null);
|
|
15
|
+
const t = ctrf.results.tool;
|
|
16
|
+
const tool = t && typeof t === 'object' ? { name: str(t.name, 100), version: str(t.version, 50) } : null;
|
|
17
|
+
const s = ctrf.results.summary;
|
|
18
|
+
let summary = null;
|
|
19
|
+
if (s && typeof s === 'object') {
|
|
20
|
+
summary = {};
|
|
21
|
+
for (const k of SUMMARY_FIELDS) {
|
|
22
|
+
const v = s[k];
|
|
23
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
24
|
+
summary[k] = v;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { specVersion: str(ctrf.specVersion, 32), tool, summary };
|
|
28
|
+
}
|
|
29
|
+
// Longest file path or configuration name written into the signed predicate.
|
|
30
|
+
const MAX_PUBLIC_PATH = 1000;
|
|
31
|
+
// A path as it is written into the signed, public predicate. Paths must stay exact so the evidence can be checked
|
|
32
|
+
// against them, so a path that redaction would change (it looks like it holds a secret or an email) or that is too
|
|
33
|
+
// long is refused rather than rewritten.
|
|
34
|
+
export function publicPath(root, file, who) {
|
|
35
|
+
const path = relPath(root, file);
|
|
36
|
+
if (clean(path, MAX_PUBLIC_PATH) !== path)
|
|
37
|
+
throw new AttestedError(`${who}: refusing to publish the path ${clean(path, 200)}: it is over ${MAX_PUBLIC_PATH} characters or looks like it contains a secret or an email; rename it`);
|
|
38
|
+
return path;
|
|
39
|
+
}
|
|
6
40
|
// Everything the receipt says about the run, computed from files under `root` only: the CTRF report, the output dir,
|
|
7
41
|
// and the test config. Used by `attested run` (root = the project) and by `attested attest --from-results`
|
|
8
42
|
// (root = a downloaded results directory, which is treated strictly as data).
|
|
9
43
|
export function assemble(root, o) {
|
|
10
44
|
const ctrfPath = resolve(root, o.ctrf);
|
|
11
|
-
if (!
|
|
45
|
+
if (!checkInside(root, ctrfPath, 'CTRF report'))
|
|
12
46
|
throw new AttestedError(`${o.who}: CTRF report not found at ${o.ctrf} (configure the CTRF reporter or pass --ctrf)`);
|
|
47
|
+
const size = statSync(ctrfPath).size;
|
|
48
|
+
if (size > MAX_CTRF_BYTES)
|
|
49
|
+
throw new AttestedError(`${o.who}: ${o.ctrf} is ${size} bytes, over the ${MAX_CTRF_BYTES}-byte limit for CTRF reports`);
|
|
50
|
+
// One descriptor: the bytes parsed are the bytes hashed into the subject, whatever happens to the path meanwhile.
|
|
51
|
+
const bytes = readInside(root, ctrfPath, MAX_CTRF_BYTES, 'CTRF report');
|
|
52
|
+
if (!bytes)
|
|
53
|
+
throw new AttestedError(`${o.who}: CTRF report not found at ${o.ctrf}`);
|
|
13
54
|
let ctrf;
|
|
14
55
|
try {
|
|
15
|
-
ctrf = JSON.parse(
|
|
56
|
+
ctrf = JSON.parse(bytes.toString('utf8'));
|
|
16
57
|
}
|
|
17
58
|
catch {
|
|
18
59
|
throw new AttestedError(`${o.who}: ${o.ctrf} is not valid JSON`);
|
|
19
60
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
61
|
+
const invalid = validateCtrf(ctrf);
|
|
62
|
+
if (invalid)
|
|
63
|
+
throw new AttestedError(`${o.who}: ${o.ctrf} is not a CTRF report (${invalid})`);
|
|
64
|
+
const subject = { sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length };
|
|
65
|
+
const artifacts = [{ path: publicPath(root, ctrfPath, o.who), ...subject, kind: 'report' }];
|
|
66
|
+
const outputDir = resolve(root, o.outputDir);
|
|
67
|
+
if (checkInside(root, outputDir, 'output directory')) {
|
|
68
|
+
for (const file of walk(outputDir))
|
|
69
|
+
artifacts.push({ path: publicPath(root, file, o.who), ...sha256File(file), kind: classify(file) });
|
|
70
|
+
}
|
|
26
71
|
const configuration = [];
|
|
27
|
-
const configPath = o.config ?? ['playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs'].find((p) =>
|
|
28
|
-
if (configPath &&
|
|
29
|
-
configuration.push({ name: configPath, digest: { sha256: sha256File(resolve(root, configPath)).sha256 } });
|
|
72
|
+
const configPath = o.config ?? ['playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs'].find((p) => checkInside(root, resolve(root, p), 'test config')) ?? null;
|
|
73
|
+
if (configPath && checkInside(root, resolve(root, configPath), 'test config'))
|
|
74
|
+
configuration.push({ name: publicPath(root, resolve(root, configPath), o.who), digest: { sha256: sha256File(resolve(root, configPath)).sha256 } });
|
|
30
75
|
if (process.env.GITHUB_WORKFLOW_REF)
|
|
31
|
-
configuration.push({ name: process.env.GITHUB_WORKFLOW_REF, digest: { gitCommit: process.env.GITHUB_SHA ?? '' } });
|
|
76
|
+
configuration.push({ name: clean(process.env.GITHUB_WORKFLOW_REF, MAX_PUBLIC_PATH), digest: { gitCommit: process.env.GITHUB_SHA ?? '' } });
|
|
32
77
|
const testResult = testResultFromCtrf(ctrf, configuration, runUrl());
|
|
33
78
|
const packet = testResult.result === 'FAILED' ? buildPacket(ctrf, artifacts, root, o.rerun) : null;
|
|
34
79
|
return { ctrf, subject, artifacts, testResult, packet };
|
|
@@ -39,7 +84,7 @@ export function writeReceipt(a, out, rest) {
|
|
|
39
84
|
rmSync(out.packet, { force: true });
|
|
40
85
|
if (a.packet) {
|
|
41
86
|
mkdirSync(dirname(out.packet), { recursive: true });
|
|
42
|
-
|
|
87
|
+
writeOutput(out.packet, serializePacket(a.packet));
|
|
43
88
|
// The packet's digest sits next to the evidence it points at.
|
|
44
89
|
artifacts.push({ path: out.packetArtifactPath, ...sha256File(out.packet), kind: 'packet' });
|
|
45
90
|
info(`failure packet: ${out.packetArtifactPath} (${a.packet.failingTests.length} failure groups${a.packet.truncated ? ', truncated' : ''})`);
|
|
@@ -50,15 +95,15 @@ export function writeReceipt(a, out, rest) {
|
|
|
50
95
|
target: rest.target,
|
|
51
96
|
artifacts,
|
|
52
97
|
tool: toolInfo(),
|
|
53
|
-
ctrf:
|
|
98
|
+
ctrf: ctrfMeta(a.ctrf),
|
|
54
99
|
createdAt: rest.createdAt,
|
|
55
100
|
};
|
|
56
101
|
const receipt = buildReceipt(a.subject.sha256, predicate);
|
|
57
102
|
mkdirSync(dirname(out.receipt), { recursive: true });
|
|
58
|
-
|
|
59
|
-
|
|
103
|
+
writeOutput(out.receipt, JSON.stringify(receipt, null, 2) + '\n');
|
|
104
|
+
writeOutput(out.predicate, JSON.stringify(predicate, null, 2) + '\n');
|
|
60
105
|
// Second, pure in-toto Test Result statement for interoperability.
|
|
61
|
-
|
|
106
|
+
writeOutput(out.testResultPredicate, JSON.stringify(a.testResult, null, 2) + '\n');
|
|
62
107
|
const tr = a.testResult;
|
|
63
108
|
info(`receipt: ${relPath(process.cwd(), out.receipt)} (${tr.result}, ${tr.passedTests.length} passed, ${tr.failedTests.length} failed, ${artifacts.length} artifacts)`);
|
|
64
109
|
if (tr.failedTests.length)
|
package/dist/attest.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync
|
|
1
|
+
import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { assemble, writeReceipt } from './assemble.js';
|
|
4
4
|
import { DEFAULTS, PREDICATE_TYPE, TEST_RESULT_PREDICATE_TYPE } from './constants.js';
|
|
5
|
-
import { AttestedError, inActions, info, readEvent, relPath, sha256File, warn } from './util.js';
|
|
5
|
+
import { AttestedError, inActions, info, readEvent, relPath, sha256File, warn, writeOutput } from './util.js';
|
|
6
6
|
export function defaultAttestOptions() {
|
|
7
7
|
return {
|
|
8
8
|
receipt: DEFAULTS.receipt, ctrf: DEFAULTS.ctrf, predicate: DEFAULTS.predicate, testResultPredicate: DEFAULTS.testResultPredicate,
|
|
@@ -22,8 +22,12 @@ function emitOutputs(subjectPath, sha256, predicatePath, testResultPath) {
|
|
|
22
22
|
// and hashed here. Everything about the source comes from the event context of this job, never from those files.
|
|
23
23
|
export function attestFromResults(opts) {
|
|
24
24
|
const root = resolve(opts.fromResults);
|
|
25
|
-
if (!existsSync(root)
|
|
25
|
+
if (!existsSync(root) && !isSymlink(root))
|
|
26
26
|
throw new AttestedError(`attested attest: results directory ${opts.fromResults} not found`);
|
|
27
|
+
if (isSymlink(root))
|
|
28
|
+
throw new AttestedError(`attested attest: results directory ${opts.fromResults} is a symlink; refusing to read it`);
|
|
29
|
+
if (!statSync(root).isDirectory())
|
|
30
|
+
throw new AttestedError(`attested attest: results directory ${opts.fromResults} is not a directory`);
|
|
27
31
|
const commit = process.env.GITHUB_SHA;
|
|
28
32
|
if (!commit)
|
|
29
33
|
throw new AttestedError('attested attest: --from-results needs GITHUB_SHA (the commit the test job checked out) from the workflow context');
|
|
@@ -45,6 +49,14 @@ export function attestFromResults(opts) {
|
|
|
45
49
|
emitOutputs(subjectPath, a.subject.sha256, paths.predicate, paths.testResultPredicate);
|
|
46
50
|
return 0;
|
|
47
51
|
}
|
|
52
|
+
function isSymlink(p) {
|
|
53
|
+
try {
|
|
54
|
+
return lstatSync(p).isSymbolicLink();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
48
60
|
function setOutput(name, value) {
|
|
49
61
|
const file = process.env.GITHUB_OUTPUT;
|
|
50
62
|
if (file)
|
|
@@ -75,8 +87,8 @@ export function attest(opts) {
|
|
|
75
87
|
}
|
|
76
88
|
// In Actions, signing is delegated to the actions/attest step that follows.
|
|
77
89
|
// The predicate file was written by `attested run`; we re-emit it from the receipt so the two cannot drift.
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
writeOutput(resolve(cwd, opts.predicate), JSON.stringify(receipt.predicate, null, 2) + '\n');
|
|
91
|
+
writeOutput(resolve(cwd, opts.testResultPredicate), JSON.stringify(receipt.predicate.testResult, null, 2) + '\n');
|
|
80
92
|
emitOutputs(ctrfPath, actual, resolve(cwd, opts.predicate), resolve(cwd, opts.testResultPredicate));
|
|
81
93
|
return 0;
|
|
82
94
|
}
|
package/dist/check.js
CHANGED
|
@@ -3,7 +3,14 @@ import { resolve } from 'node:path';
|
|
|
3
3
|
import { DEFAULTS, PREDICATE_TYPE, TOOL_NAME } from './constants.js';
|
|
4
4
|
import { AttestedError, info, readEvent, runUrl, signerWorkflow } from './util.js';
|
|
5
5
|
export function defaultCheckOptions() {
|
|
6
|
-
return { receipt: DEFAULTS.receipt, packet: DEFAULTS.packet, artifactUrl: null, artifactName: null, attestationUrl: null };
|
|
6
|
+
return { receipt: DEFAULTS.receipt, packet: DEFAULTS.packet, artifactUrl: null, artifactName: null, evidenceName: null, attestationUrl: null };
|
|
7
|
+
}
|
|
8
|
+
// Selects the statement `attested verify` accepts from `gh attestation verify --format json`: the certificate names
|
|
9
|
+
// this run and ref, and the predicate's commit or PR head is the head the check is attached to. Null (jq -e fails) when none.
|
|
10
|
+
const STATEMENT_JQ = `[.[].verificationResult | select(.signature.certificate.sourceRepositoryRef == $ref and ((.signature.certificate.runInvocationURI // "") | test("/actions/runs/" + $run + "(/attempts/[0-9]+)?$"))) | .statement | select(.predicateType == "${PREDICATE_TYPE}" and (.predicate.source.commit == $head or .predicate.source.headCommit == $head))] | first`;
|
|
11
|
+
const SUM_LINE = '"\\(.sha256) \\(.path)"';
|
|
12
|
+
function shellQuote(s) {
|
|
13
|
+
return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
7
14
|
}
|
|
8
15
|
export function conclusionFor(result) {
|
|
9
16
|
if (result === 'PASSED')
|
|
@@ -49,10 +56,36 @@ export function buildSummary(receipt, packet, opts) {
|
|
|
49
56
|
lines.push(`| Run | ${url} |`);
|
|
50
57
|
lines.push('', 'Verify this receipt yourself:', '', '```bash');
|
|
51
58
|
const runId = process.env.GITHUB_RUN_ID;
|
|
59
|
+
const ref = process.env.GITHUB_REF;
|
|
60
|
+
const headSha = checkHeadSha();
|
|
52
61
|
if (runId && opts.artifactName)
|
|
53
62
|
lines.push(`gh run download ${runId} --repo ${repo} --name ${opts.artifactName} --dir attested-receipt`);
|
|
54
|
-
|
|
63
|
+
const verify = `gh attestation verify attested-receipt/attested/ctrf.json --repo ${repo} --predicate-type ${PREDICATE_TYPE} --signer-workflow ${signer}`;
|
|
64
|
+
// The accepted statement is the one `attested verify` would accept: signed in this run, on this ref, for this head.
|
|
65
|
+
// Every later check reads the statement, never the downloaded receipt.json, which is unsigned.
|
|
66
|
+
const pinned = Boolean(runId && ref && headSha);
|
|
67
|
+
if (pinned) {
|
|
68
|
+
lines.push(`${verify} --format json > attested-verify.json`);
|
|
69
|
+
lines.push(`jq -e --arg run ${runId} --arg ref ${shellQuote(ref)} --arg head ${headSha} '${STATEMENT_JQ}' attested-verify.json > attested-statement.json`);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
lines.push(verify);
|
|
73
|
+
}
|
|
55
74
|
lines.push('```');
|
|
75
|
+
// The evidence travels in its own artifact. Its name defaults to the bundled workflow's (results-<sha> beside
|
|
76
|
+
// attested-<sha>), as the signer workflow above defaults to the bundled attested.yml.
|
|
77
|
+
const evidence = opts.evidenceName ?? (opts.artifactName?.startsWith('attested-') ? `results-${opts.artifactName.slice('attested-'.length)}` : null);
|
|
78
|
+
if (pinned && evidence) {
|
|
79
|
+
lines.push('', 'Get the evidence (screenshots, videos, traces) and check each file against the fingerprints in the signed statement:', '', '```bash');
|
|
80
|
+
lines.push(`gh run download ${runId} --repo ${repo} --name ${evidence} --dir attested-evidence`);
|
|
81
|
+
// The sign job writes the failure packet into the receipt artifact, so packet entries are checked there.
|
|
82
|
+
lines.push(`(cd attested-evidence && jq -r '.predicate.artifacts[] | select(.kind != "packet") | ${SUM_LINE}' ../attested-statement.json | shasum -a 256 -c)`);
|
|
83
|
+
if (p.artifacts?.some((a) => a.kind === 'packet')) {
|
|
84
|
+
lines.push(`(cd attested-receipt && jq -r '.predicate.artifacts[] | select(.kind == "packet") | ${SUM_LINE}' ../attested-statement.json | shasum -a 256 -c)`);
|
|
85
|
+
}
|
|
86
|
+
lines.push('```');
|
|
87
|
+
lines.push('', "Artifacts follow this repository's artifact retention (90 days by default); the receipt's digests and signature outlast them.");
|
|
88
|
+
}
|
|
56
89
|
if (tr.failedTests.length && packet) {
|
|
57
90
|
lines.push('', 'Failure packet (`attested/packet.json`):', '', '```json', packet.trimEnd(), '```');
|
|
58
91
|
}
|
|
@@ -97,7 +130,7 @@ export async function check(opts) {
|
|
|
97
130
|
status: 'completed',
|
|
98
131
|
conclusion,
|
|
99
132
|
details_url: runUrl() ?? undefined,
|
|
100
|
-
// Text only. The `images` field is never used: evidence stays in the
|
|
133
|
+
// Text only. The `images` field is never used: evidence stays in the workflow artifact, as visible as the repository's Actions artifacts (PixelLeak rule).
|
|
101
134
|
output: { title, summary },
|
|
102
135
|
};
|
|
103
136
|
const res = await fetch(`${api}/repos/${repo}/check-runs`, {
|
package/dist/constants.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Single place to rename the project or move the predicate URI.
|
|
2
2
|
export const TOOL_NAME = 'attested';
|
|
3
|
-
|
|
3
|
+
// Must equal packages/cli/package.json version; test/version.test.mjs enforces it.
|
|
4
|
+
export const TOOL_VERSION = '0.1.1';
|
|
4
5
|
export const PREDICATE_TYPE = 'https://praxso.ai/attested/v0.1';
|
|
5
6
|
export const TEST_RESULT_PREDICATE_TYPE = 'https://in-toto.io/attestation/test-result/v0.1';
|
|
6
7
|
export const STATEMENT_TYPE = 'https://in-toto.io/Statement/v1';
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,8 @@ attest options:
|
|
|
31
31
|
check options (posts the "attested" check run; needs GITHUB_TOKEN with checks: write):
|
|
32
32
|
--artifact-url <url> link to the uploaded workflow artifact
|
|
33
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>)
|
|
34
36
|
--attestation-url <url> link to the receipt attestation
|
|
35
37
|
--receipt <path>, --packet <path>
|
|
36
38
|
`;
|
|
@@ -124,6 +126,8 @@ async function main(argv) {
|
|
|
124
126
|
o.artifactUrl = optValue(rest, i++, a);
|
|
125
127
|
else if (a === '--artifact-name')
|
|
126
128
|
o.artifactName = optValue(rest, i++, a);
|
|
129
|
+
else if (a === '--evidence-name')
|
|
130
|
+
o.evidenceName = optValue(rest, i++, a);
|
|
127
131
|
else if (a === '--attestation-url')
|
|
128
132
|
o.attestationUrl = optValue(rest, i++, a);
|
|
129
133
|
else
|
package/dist/packet.js
CHANGED
|
@@ -1,41 +1,36 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { inflateRawSync } from 'node:zlib';
|
|
3
3
|
import { isAbsolute, relative, resolve } from 'node:path';
|
|
4
4
|
import { TOOL_NAME } from './constants.js';
|
|
5
5
|
import { testNameOf } from './receipt.js';
|
|
6
|
-
import {
|
|
6
|
+
import { ANSI, clean, clip, stripUrl } from './redact.js';
|
|
7
|
+
import { relPath, warn } from './util.js';
|
|
8
|
+
export { redact, stripUrl } from './redact.js';
|
|
7
9
|
// The failure packet: deterministic, model-free, at most 12 KB. Written on red so an agent can fix without opening traces.
|
|
8
10
|
export const PACKET_MAX_BYTES = 12 * 1024;
|
|
9
11
|
const MAX_FRAMES = 20;
|
|
10
12
|
const MAX_MESSAGE = 1500;
|
|
11
|
-
//
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
export
|
|
22
|
-
return u.replace(/[?#].*$/, '');
|
|
23
|
-
}
|
|
24
|
-
// Strip ANSI, URL queries and fragments, and anything shaped like a secret or an email.
|
|
25
|
-
export function redact(text) {
|
|
26
|
-
let out = text.replace(ANSI, '').replace(URL_RE, (u) => stripUrl(u));
|
|
27
|
-
for (const [re, rep] of SECRET_PATTERNS)
|
|
28
|
-
out = out.replace(re, rep);
|
|
29
|
-
return out;
|
|
30
|
-
}
|
|
31
|
-
function clip(text, max) {
|
|
32
|
-
return text.length > max ? `${text.slice(0, max)}… [${text.length - max} chars cut]` : text;
|
|
33
|
-
}
|
|
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;
|
|
34
24
|
// --- minimal zip reader (Playwright trace.zip), stored and deflate entries only --------------
|
|
35
|
-
|
|
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 }) {
|
|
36
27
|
const out = new Map();
|
|
37
28
|
let buf;
|
|
38
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
|
+
}
|
|
39
34
|
buf = readFileSync(path);
|
|
40
35
|
}
|
|
41
36
|
catch {
|
|
@@ -64,13 +59,33 @@ export function readZip(path, want) {
|
|
|
64
59
|
continue;
|
|
65
60
|
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
|
|
66
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);
|
|
67
68
|
try {
|
|
69
|
+
let text;
|
|
68
70
|
if (method === 0)
|
|
69
|
-
|
|
71
|
+
text = data;
|
|
70
72
|
else if (method === 8)
|
|
71
|
-
|
|
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
|
+
}
|
|
72
88
|
}
|
|
73
|
-
catch { /* unreadable entry: skip */ }
|
|
74
89
|
}
|
|
75
90
|
return out;
|
|
76
91
|
}
|
|
@@ -91,11 +106,14 @@ export function readTraces(paths) {
|
|
|
91
106
|
const cons = new Map();
|
|
92
107
|
const net = new Map();
|
|
93
108
|
let seq = 0;
|
|
109
|
+
const budget = { left: MAX_TRACE_TOTAL_BYTES };
|
|
94
110
|
for (const path of paths) {
|
|
111
|
+
if (budget.left <= 0)
|
|
112
|
+
break;
|
|
95
113
|
let files;
|
|
96
114
|
// Trace zips can come from an untrusted results artifact: a malformed one is skipped, never fatal.
|
|
97
115
|
try {
|
|
98
|
-
files = readZip(path, (n) => n.endsWith('.trace') || n.endsWith('.network'));
|
|
116
|
+
files = readZip(path, (n) => n.endsWith('.trace') || n.endsWith('.network'), budget);
|
|
99
117
|
}
|
|
100
118
|
catch {
|
|
101
119
|
continue;
|
|
@@ -105,7 +123,7 @@ export function readTraces(paths) {
|
|
|
105
123
|
seq++;
|
|
106
124
|
if (ev.type === 'console' && (ev.messageType === 'error' || ev.messageType === 'warning')) {
|
|
107
125
|
const level = ev.messageType;
|
|
108
|
-
const message =
|
|
126
|
+
const message = clean(ev.text ?? '', 300);
|
|
109
127
|
const key = `${level}\u0000${message}`;
|
|
110
128
|
const e = cons.get(key) ?? { level, message, count: 0, lastSeen: 0 };
|
|
111
129
|
e.count++;
|
|
@@ -114,7 +132,7 @@ export function readTraces(paths) {
|
|
|
114
132
|
}
|
|
115
133
|
else if (ev.type === 'event' && ev.method === 'pageError') {
|
|
116
134
|
const err = ev.params?.error?.error ?? ev.params?.error ?? {};
|
|
117
|
-
const message =
|
|
135
|
+
const message = clean(`${err.name ?? 'Error'}: ${err.message ?? ''}`, 300);
|
|
118
136
|
const key = `error\u0000${message}`;
|
|
119
137
|
const e = cons.get(key) ?? { level: 'error', message, count: 0, lastSeen: 0 };
|
|
120
138
|
e.count++;
|
|
@@ -122,7 +140,8 @@ export function readTraces(paths) {
|
|
|
122
140
|
cons.set(key, e);
|
|
123
141
|
}
|
|
124
142
|
else if (ev.type === 'resource-snapshot' && ev.snapshot?.request) {
|
|
125
|
-
const
|
|
143
|
+
const raw = Number(ev.snapshot.response?.status ?? -1);
|
|
144
|
+
const status = Number.isInteger(raw) && raw >= -1 && raw < 1000 ? raw : -1;
|
|
126
145
|
const failed = status >= 400 || status <= 0 || Boolean(ev.snapshot.response?._failureText);
|
|
127
146
|
if (!failed)
|
|
128
147
|
continue;
|
|
@@ -133,8 +152,8 @@ export function readTraces(paths) {
|
|
|
133
152
|
catch {
|
|
134
153
|
path = stripUrl(path);
|
|
135
154
|
}
|
|
136
|
-
const method =
|
|
137
|
-
path =
|
|
155
|
+
const method = clean(ev.snapshot.request.method ?? 'GET', MAX_METHOD);
|
|
156
|
+
path = clean(path, MAX_PATH);
|
|
138
157
|
const key = `${method} ${path} ${status}`;
|
|
139
158
|
const e = net.get(key) ?? { method, path, status, count: 0, lastSeen: 0 };
|
|
140
159
|
e.count++;
|
|
@@ -151,20 +170,24 @@ function shellQuote(a) {
|
|
|
151
170
|
return /^[A-Za-z0-9_\/.,:=@%+-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`;
|
|
152
171
|
}
|
|
153
172
|
export function rerunCommand(argv) {
|
|
154
|
-
return ['attested', ...argv].map(shellQuote).join(' ');
|
|
173
|
+
return clean(['attested', ...argv].map(shellQuote).join(' '), MAX_RERUN);
|
|
155
174
|
}
|
|
156
175
|
function lineOf(t, file, stack) {
|
|
157
176
|
if (typeof t.line === 'number')
|
|
158
|
-
return t.line;
|
|
177
|
+
return Number.isInteger(t.line) && t.line >= 0 && t.line < 1e9 ? t.line : null;
|
|
159
178
|
if (!file)
|
|
160
179
|
return null;
|
|
161
180
|
for (const f of stack) {
|
|
162
|
-
const m = f.includes(file) ? /:(\d
|
|
181
|
+
const m = f.includes(file) ? /:(\d{1,9}):\d+\)?$/.exec(f) : null;
|
|
163
182
|
if (m)
|
|
164
183
|
return Number(m[1]);
|
|
165
184
|
}
|
|
166
185
|
return null;
|
|
167
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
|
+
}
|
|
168
191
|
export function buildPacket(ctrf, artifacts, cwd, rerun) {
|
|
169
192
|
const tests = (ctrf.results.tests ?? []).filter((t) => t.status === 'failed');
|
|
170
193
|
const byPath = new Map(artifacts.map((a) => [a.path, a]));
|
|
@@ -174,9 +197,7 @@ export function buildPacket(ctrf, artifacts, cwd, rerun) {
|
|
|
174
197
|
const lookup = (p) => byPath.get(rel(p)) ?? artifacts.find((a) => p.split('\\').join('/').endsWith('/' + a.path));
|
|
175
198
|
let srcRoot = cwd;
|
|
176
199
|
find: for (const t of tests) {
|
|
177
|
-
for (const a of t
|
|
178
|
-
if (!a.path)
|
|
179
|
-
continue;
|
|
200
|
+
for (const a of attachmentsOf(t)) {
|
|
180
201
|
if (!byPath.has(rel(a.path))) {
|
|
181
202
|
const art = lookup(a.path);
|
|
182
203
|
if (art)
|
|
@@ -188,23 +209,22 @@ export function buildPacket(ctrf, artifacts, cwd, rerun) {
|
|
|
188
209
|
const traces = [];
|
|
189
210
|
const groups = new Map();
|
|
190
211
|
for (const t of tests) {
|
|
191
|
-
const raw = (t.trace ?? t.message ?? '').replace(ANSI, '');
|
|
212
|
+
const raw = String(t.trace ?? t.message ?? '').replace(ANSI, '');
|
|
192
213
|
const stack = raw.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('at ')).slice(0, MAX_FRAMES)
|
|
193
|
-
.map((f) =>
|
|
194
|
-
const message =
|
|
195
|
-
const file = t.filePath ? relative(srcRoot, t.filePath).split('\\').join('/') : null;
|
|
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;
|
|
196
217
|
const evidence = [];
|
|
197
|
-
for (const a of t
|
|
198
|
-
if (!a.path)
|
|
199
|
-
continue;
|
|
218
|
+
for (const a of attachmentsOf(t)) {
|
|
200
219
|
const art = lookup(a.path);
|
|
201
220
|
if (!art || !['trace', 'video', 'screenshot'].includes(art.kind))
|
|
202
221
|
continue;
|
|
203
|
-
evidence.
|
|
222
|
+
if (evidence.length < MAX_EVIDENCE)
|
|
223
|
+
evidence.push({ kind: art.kind, path: clean(art.path, MAX_PATH), sha256: art.sha256 });
|
|
204
224
|
if (art.kind === 'trace' && existsSync(resolve(cwd, art.path)))
|
|
205
225
|
traces.push(resolve(cwd, art.path));
|
|
206
226
|
}
|
|
207
|
-
const name = testNameOf(t);
|
|
227
|
+
const name = clean(testNameOf(t), MAX_NAME);
|
|
208
228
|
// Identical failures (same file, line, and message) collapse into one entry with a count.
|
|
209
229
|
const key = `${file}\u0000${lineOf(t, file, stack)}\u0000${message}`;
|
|
210
230
|
const g = groups.get(key);
|
|
@@ -222,7 +242,7 @@ export function buildPacket(ctrf, artifacts, cwd, rerun) {
|
|
|
222
242
|
failingTests: [...groups.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
|
223
243
|
console: byRecent(cons),
|
|
224
244
|
network: byRecent(network),
|
|
225
|
-
rerun,
|
|
245
|
+
rerun: clean(rerun, MAX_RERUN),
|
|
226
246
|
truncated: false,
|
|
227
247
|
};
|
|
228
248
|
return capPacket(packet);
|
|
@@ -250,5 +270,8 @@ export function capPacket(p) {
|
|
|
250
270
|
list.pop();
|
|
251
271
|
cut[name]++;
|
|
252
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);
|
|
253
276
|
return p;
|
|
254
277
|
}
|
package/dist/receipt.js
CHANGED
|
@@ -1,17 +1,48 @@
|
|
|
1
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
|
+
}
|
|
2
19
|
export function testNameOf(t) {
|
|
3
20
|
const prefix = t.suite ?? t.filePath;
|
|
4
21
|
return prefix ? `${prefix} > ${t.name}` : t.name;
|
|
5
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);
|
|
6
26
|
export function testResultFromCtrf(ctrf, configuration, url) {
|
|
7
|
-
const tests = ctrf.results.tests
|
|
8
|
-
|
|
9
|
-
const
|
|
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);
|
|
10
35
|
// CTRF has no "warned" status; flaky/other/skipped/pending land here so nothing is silently dropped.
|
|
11
|
-
const warnedTests = tests.filter((t) =>
|
|
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)}]`);
|
|
12
38
|
const summary = ctrf.results.summary ?? {};
|
|
13
|
-
const
|
|
14
|
-
|
|
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';
|
|
15
46
|
const tr = { result, configuration, passedTests, warnedTests, failedTests };
|
|
16
47
|
if (url)
|
|
17
48
|
tr.url = url;
|
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
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdirSync, rmSync } from 'node:fs';
|
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
4
|
import { DEFAULTS } from './constants.js';
|
|
5
5
|
import { assemble, writeReceipt } from './assemble.js';
|
|
6
|
+
import { clean, stripUrl } from './redact.js';
|
|
6
7
|
import { rerunCommand } from './packet.js';
|
|
7
8
|
import { AttestedError, gitDirty, gitHead, gitRef, info, readEvent, relPath } from './util.js';
|
|
8
9
|
export function defaultRunOptions() {
|
|
@@ -22,9 +23,16 @@ export function defaultRunOptions() {
|
|
|
22
23
|
argv: [],
|
|
23
24
|
};
|
|
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
|
+
}
|
|
25
30
|
// Probe <url><healthPath> and require a `sha` field equal to expectedSha. Any failure is fatal:
|
|
26
31
|
// a receipt must not claim tests ran against a deployment it cannot identify.
|
|
27
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)}`);
|
|
28
36
|
const target = new URL(healthPath, url).toString();
|
|
29
37
|
let res;
|
|
30
38
|
let text;
|
|
@@ -49,7 +57,8 @@ async function probeTarget(url, healthPath, expectedSha) {
|
|
|
49
57
|
if (buildSha !== expectedSha) {
|
|
50
58
|
throw new AttestedError(`attested run: target ${target} is built from ${buildSha}, expected ${expectedSha}; refusing to run tests`);
|
|
51
59
|
}
|
|
52
|
-
|
|
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 } };
|
|
53
62
|
}
|
|
54
63
|
export async function run(opts) {
|
|
55
64
|
const cwd = process.cwd();
|
package/dist/util.js
CHANGED
|
@@ -1,12 +1,129 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
-
import { join, relative, extname } from 'node:path';
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, extname, sep } from 'node:path';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
export class AttestedError extends Error {
|
|
6
6
|
}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// Largest evidence file (trace, video, screenshot, report) that is hashed into a receipt.
|
|
8
|
+
export const MAX_EVIDENCE_BYTES = 512 * 1024 * 1024;
|
|
9
|
+
// Opens a regular file without following a symlink in its last component.
|
|
10
|
+
function openFile(path, what = 'file') {
|
|
11
|
+
let fd;
|
|
12
|
+
try {
|
|
13
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
if (err.code === 'ELOOP')
|
|
17
|
+
throw new AttestedError(`attested: ${what} ${path} is a symlink; refusing to read it`);
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
if (!fstatSync(fd).isFile()) {
|
|
21
|
+
closeSync(fd);
|
|
22
|
+
throw new AttestedError(`attested: ${what} ${path} is not a regular file; refusing to read it`);
|
|
23
|
+
}
|
|
24
|
+
return fd;
|
|
25
|
+
}
|
|
26
|
+
// Writes an output file without following a symlink: a committed `attested/receipt.json -> ../elsewhere` (or a
|
|
27
|
+
// symlinked output directory) would otherwise make a run overwrite a file outside the project. The data goes to a
|
|
28
|
+
// fresh temp file in the same directory, then rename() replaces the target itself, never what a link points at.
|
|
29
|
+
export function writeOutput(path, data) {
|
|
30
|
+
const dir = dirname(path);
|
|
31
|
+
if (isLink(dir))
|
|
32
|
+
throw new AttestedError(`attested: output directory ${dir} is a symlink; refusing to write through it`);
|
|
33
|
+
if (isLink(path))
|
|
34
|
+
throw new AttestedError(`attested: output ${path} is a symlink; refusing to write through it`);
|
|
35
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
36
|
+
try {
|
|
37
|
+
writeFileSync(tmp, data, { flag: 'wx' });
|
|
38
|
+
renameSync(tmp, path);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
rmSync(tmp, { force: true });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function isLink(path) {
|
|
45
|
+
try {
|
|
46
|
+
return lstatSync(path).isSymbolicLink();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Hashes in chunks, so a large evidence file is never held in memory; refuses files over `max` bytes.
|
|
53
|
+
export function sha256File(path, max = MAX_EVIDENCE_BYTES) {
|
|
54
|
+
const fd = openFile(path);
|
|
55
|
+
try {
|
|
56
|
+
const size = fstatSync(fd).size;
|
|
57
|
+
if (size > max)
|
|
58
|
+
throw new AttestedError(`attested: ${path} is ${size} bytes, over the ${max}-byte limit for evidence files`);
|
|
59
|
+
const hash = createHash('sha256');
|
|
60
|
+
const chunk = Buffer.alloc(1024 * 1024);
|
|
61
|
+
let bytes = 0;
|
|
62
|
+
for (let n; (n = readSync(fd, chunk, 0, chunk.length, null)) > 0;) {
|
|
63
|
+
bytes += n;
|
|
64
|
+
if (bytes > max)
|
|
65
|
+
throw new AttestedError(`attested: ${path} grew past the ${max}-byte limit for evidence files while hashing`);
|
|
66
|
+
hash.update(chunk.subarray(0, n));
|
|
67
|
+
}
|
|
68
|
+
return { sha256: hash.digest('hex'), bytes };
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
closeSync(fd);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// `path` (a file or directory under `root`) must not be a symlink and must resolve inside `root`, including through
|
|
75
|
+
// symlinked parent directories. Returns false when it does not exist; throws when it escapes.
|
|
76
|
+
export function checkInside(root, path, what) {
|
|
77
|
+
let st;
|
|
78
|
+
try {
|
|
79
|
+
st = lstatSync(path);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (st.isSymbolicLink())
|
|
85
|
+
throw new AttestedError(`attested: ${what} ${path} is a symlink; refusing to read it`);
|
|
86
|
+
const rel = relative(realpathSync(root), realpathSync(path));
|
|
87
|
+
if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
|
|
88
|
+
throw new AttestedError(`attested: ${what} ${path} resolves outside ${root}; refusing to read it`);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
// Reads `path` (under `root`) through one descriptor: opened first, then checked with checkInside and required to be the
|
|
92
|
+
// same file the path names now, and read with `max` enforced on the bytes actually read. What is checked, read, and
|
|
93
|
+
// hashed is one object, even if the path is replaced or the file grows meanwhile. Returns null when it does not exist.
|
|
94
|
+
// `afterOpen` is a test seam for changing the file between opening and validation.
|
|
95
|
+
export function readInside(root, path, max, what, afterOpen) {
|
|
96
|
+
let fd;
|
|
97
|
+
try {
|
|
98
|
+
fd = openFile(path, what);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
if (err.code === 'ENOENT')
|
|
102
|
+
return null;
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
afterOpen?.();
|
|
107
|
+
if (!checkInside(root, path, what))
|
|
108
|
+
throw new AttestedError(`attested: ${what} ${path} was removed while being read; refusing to read it`);
|
|
109
|
+
const st = fstatSync(fd);
|
|
110
|
+
const now = lstatSync(path);
|
|
111
|
+
if (st.dev !== now.dev || st.ino !== now.ino)
|
|
112
|
+
throw new AttestedError(`attested: ${what} ${path} was replaced while being read; refusing to read it`);
|
|
113
|
+
const chunks = [];
|
|
114
|
+
const chunk = Buffer.alloc(1024 * 1024);
|
|
115
|
+
let bytes = 0;
|
|
116
|
+
for (let n; (n = readSync(fd, chunk, 0, chunk.length, null)) > 0;) {
|
|
117
|
+
bytes += n;
|
|
118
|
+
if (bytes > max)
|
|
119
|
+
throw new AttestedError(`attested: ${what} ${path} is over the ${max}-byte limit`);
|
|
120
|
+
chunks.push(Buffer.from(chunk.subarray(0, n)));
|
|
121
|
+
}
|
|
122
|
+
return Buffer.concat(chunks);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
closeSync(fd);
|
|
126
|
+
}
|
|
10
127
|
}
|
|
11
128
|
export function walk(dir) {
|
|
12
129
|
let out = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@praxso/attested",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Signed, verifiable test receipts for agent-authored pull requests: wraps Playwright or any CTRF test run into an in-toto receipt signed with Sigstore via GitHub artifact attestations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"attestation",
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
"files": [
|
|
35
35
|
"bin",
|
|
36
36
|
"dist",
|
|
37
|
-
"README.md"
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
38
39
|
],
|
|
39
40
|
"engines": {
|
|
40
41
|
"node": ">=22"
|