@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 CHANGED
@@ -1,34 +1,79 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
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 { AttestedError, classify, info, relPath, runUrl, sha256File, walk } from './util.js';
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 (!existsSync(ctrfPath))
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(readFileSync(ctrfPath, 'utf8'));
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
- if (!ctrf || typeof ctrf !== 'object' || !ctrf.results)
21
- throw new AttestedError(`${o.who}: ${o.ctrf} is not a CTRF report (missing "results")`);
22
- const subject = sha256File(ctrfPath);
23
- const artifacts = [{ path: relPath(root, ctrfPath), ...subject, kind: 'report' }];
24
- for (const file of walk(resolve(root, o.outputDir)))
25
- artifacts.push({ path: relPath(root, file), ...sha256File(file), kind: classify(file) });
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) => existsSync(resolve(root, p))) ?? null;
28
- if (configPath && existsSync(resolve(root, 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
- writeFileSync(out.packet, serializePacket(a.packet));
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: { specVersion: a.ctrf.specVersion ?? null, tool: a.ctrf.results.tool ?? null, summary: a.ctrf.results.summary ?? null },
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
- writeFileSync(out.receipt, JSON.stringify(receipt, null, 2) + '\n');
59
- writeFileSync(out.predicate, JSON.stringify(predicate, null, 2) + '\n');
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
- writeFileSync(out.testResultPredicate, JSON.stringify(a.testResult, null, 2) + '\n');
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, writeFileSync } from 'node:fs';
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) || !statSync(root).isDirectory())
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
- writeFileSync(resolve(cwd, opts.predicate), JSON.stringify(receipt.predicate, null, 2) + '\n');
79
- writeFileSync(resolve(cwd, opts.testResultPredicate), JSON.stringify(receipt.predicate.testResult, null, 2) + '\n');
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
- lines.push(`gh attestation verify attested-receipt/attested/ctrf.json --repo ${repo} --predicate-type ${PREDICATE_TYPE} --signer-workflow ${signer}`);
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 repo-private workflow artifact (PixelLeak rule).
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
- export const TOOL_VERSION = '0.1.0';
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 { relPath } from './util.js';
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
- // --- redaction ---------------------------------------------------------------------------------
12
- const ANSI = /\u001b\[[0-9;?]*[ -\/]*[@-~]/g;
13
- const SECRET_PATTERNS = [
14
- [/\b(bearer|basic|token)\s+[A-Za-z0-9\-._~+\/]{8,}=*/gi, '$1 [REDACTED]'],
15
- [/\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,}|AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})\b/g, '[REDACTED]'],
16
- [/\b([A-Za-z0-9_-]*(?:key|token|secret|password|passwd|auth|session|signature|credential)[A-Za-z0-9_-]*)(["']?\s*[=:]\s*["']?)(?!(?:bearer|basic|token)\s)[^\s"'&,;)}\]]+/gi, '$1$2[REDACTED]'],
17
- [/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '[EMAIL]'],
18
- ];
19
- const URL_RE = /\b(https?|wss?):\/\/[^\s"'<>)\]]+/gi;
20
- // Drop the query string and fragment of a URL.
21
- export function stripUrl(u) {
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
- export function readZip(path, want) {
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
- out.set(name, data.toString('utf8'));
71
+ text = data;
70
72
  else if (method === 8)
71
- out.set(name, inflateRawSync(data).toString('utf8'));
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 = clip(redact(String(ev.text ?? '')), 300);
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 = clip(redact(`${err.name ?? 'Error'}: ${err.message ?? ''}`), 300);
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 status = Number(ev.snapshot.response?.status ?? -1);
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 = String(ev.snapshot.request.method ?? 'GET');
137
- path = redact(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+):\d+\)?$/.exec(f) : null;
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.attachments ?? []) {
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) => redact(f.split(srcRoot + '/').join('')));
194
- const message = clip(redact((t.message ?? '').replace(ANSI, '')), MAX_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.attachments ?? []) {
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.push({ kind: art.kind, path: art.path, sha256: art.sha256 });
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
- const passedTests = tests.filter((t) => t.status === 'passed').map(testNameOf);
9
- const failedTests = tests.filter((t) => t.status === 'failed').map(testNameOf);
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) => !['passed', 'failed'].includes(t.status)).map((t) => `${testNameOf(t)} [${t.status}]`);
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 failed = (summary.failed ?? failedTests.length) > 0;
14
- const result = failed ? 'FAILED' : warnedTests.length > 0 ? 'WARNED' : 'PASSED';
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
- return { buildSha, url, health: { probed: target, status: res.status, body } };
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
- export function sha256File(path) {
8
- const buf = readFileSync(path);
9
- return { sha256: createHash('sha256').update(buf).digest('hex'), bytes: buf.length };
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.0",
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"