@forwardimpact/libharness 1.2.1 → 1.3.0
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/bin/fit-harness.js +30 -0
- package/package.json +1 -1
- package/src/commands/scan-logs.js +155 -0
package/bin/fit-harness.js
CHANGED
|
@@ -13,6 +13,7 @@ import { runSuperviseCommand } from "../src/commands/supervise.js";
|
|
|
13
13
|
import { runFacilitateCommand } from "../src/commands/facilitate.js";
|
|
14
14
|
import { runDiscussCommand } from "../src/commands/discuss.js";
|
|
15
15
|
import { runCallbackCommand } from "../src/commands/callback.js";
|
|
16
|
+
import { runScanLogsCommand } from "../src/commands/scan-logs.js";
|
|
16
17
|
import { AGENT_MODEL, LEAD_MODEL } from "@forwardimpact/libutil/models";
|
|
17
18
|
|
|
18
19
|
const LEAD_OPTIONS = {
|
|
@@ -279,6 +280,35 @@ const definition = {
|
|
|
279
280
|
},
|
|
280
281
|
},
|
|
281
282
|
},
|
|
283
|
+
{
|
|
284
|
+
name: "scan-logs",
|
|
285
|
+
args: [],
|
|
286
|
+
argsUsage: "",
|
|
287
|
+
handler: runScanLogsCommand,
|
|
288
|
+
description:
|
|
289
|
+
"Scan a run's log archive for secret literals; exit non-zero on any hit, fail closed on an unreadable archive",
|
|
290
|
+
options: {
|
|
291
|
+
archive: {
|
|
292
|
+
type: "string",
|
|
293
|
+
description: "Path to an already-resolved log archive (.zip)",
|
|
294
|
+
},
|
|
295
|
+
"run-id": {
|
|
296
|
+
type: "string",
|
|
297
|
+
description:
|
|
298
|
+
"GitHub Actions run id to download the log archive for (with --repo)",
|
|
299
|
+
},
|
|
300
|
+
repo: {
|
|
301
|
+
type: "string",
|
|
302
|
+
description: "owner/repo for the --run-id download",
|
|
303
|
+
},
|
|
304
|
+
secret: {
|
|
305
|
+
type: "string",
|
|
306
|
+
multiple: true,
|
|
307
|
+
description:
|
|
308
|
+
"Repeatable label=literal; the literal (everything after the first =) is searched for in the logs",
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
},
|
|
282
312
|
],
|
|
283
313
|
globalOptions: {
|
|
284
314
|
format: { type: "string", description: "Output format (json|text)" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libharness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Autonomous agent team harness — coordinate a lead and participant agents in one async session, with eval, benchmark, and trace tooling to prove the changes worked.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"orchestration",
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fit-harness scan-logs` — scan a run's log archive for secret literals and
|
|
3
|
+
* fail closed.
|
|
4
|
+
*
|
|
5
|
+
* A run-lifecycle concern (not an NDJSON trace, so it lives here rather than
|
|
6
|
+
* in `fit-trace`): after a CI run that handled secrets, download or accept the
|
|
7
|
+
* run's own log archive and assert none of a supplied set of literals leaked
|
|
8
|
+
* into it. Any hit exits non-zero; any download/extract failure also exits
|
|
9
|
+
* non-zero — the gate must never silently disarm.
|
|
10
|
+
*
|
|
11
|
+
* Log resolution:
|
|
12
|
+
* - `--archive <zip>` — an already-resolved archive (extracted locally).
|
|
13
|
+
* - `--run-id <id> --repo <owner/repo>` — download this run's archive via
|
|
14
|
+
* `gh` first, then extract.
|
|
15
|
+
*
|
|
16
|
+
* Secrets are `--secret <label>=<literal>`, repeatable. The literal is
|
|
17
|
+
* everything after the FIRST `=` (JWTs and base64 keys contain `=`); the label
|
|
18
|
+
* is only cosmetic, named in the `FAIL:` line.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse repeatable `--secret label=literal` flags. libcli's `multiple: true`
|
|
25
|
+
* yields an array from node's parseArgs in every case; tolerate a bare string
|
|
26
|
+
* or undefined defensively. Split on the FIRST `=` only.
|
|
27
|
+
*
|
|
28
|
+
* @param {string[]|string|undefined} secretOpt
|
|
29
|
+
* @returns {{label: string, literal: string}[]}
|
|
30
|
+
*/
|
|
31
|
+
export function parseSecrets(secretOpt) {
|
|
32
|
+
const arr = Array.isArray(secretOpt)
|
|
33
|
+
? secretOpt
|
|
34
|
+
: secretOpt
|
|
35
|
+
? [secretOpt]
|
|
36
|
+
: [];
|
|
37
|
+
return arr.map((s) => {
|
|
38
|
+
const idx = s.indexOf("=");
|
|
39
|
+
if (idx === -1) return { label: s, literal: "" };
|
|
40
|
+
return { label: s.slice(0, idx), literal: s.slice(idx + 1) };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Walk a directory tree and return every file path. Uses per-level readdir so
|
|
46
|
+
* it works against both node:fs and the libmock fs (no `recursive` reliance).
|
|
47
|
+
*/
|
|
48
|
+
async function collectFiles(dir, runtime) {
|
|
49
|
+
const out = [];
|
|
50
|
+
const entries = await runtime.fs.readdir(dir, { withFileTypes: true });
|
|
51
|
+
for (const ent of entries) {
|
|
52
|
+
const full = join(dir, ent.name);
|
|
53
|
+
if (ent.isDirectory()) {
|
|
54
|
+
out.push(...(await collectFiles(full, runtime)));
|
|
55
|
+
} else if (ent.isFile()) {
|
|
56
|
+
out.push(full);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Scan every file under `dir` for each secret literal. Returns the labels of
|
|
64
|
+
* secrets whose non-empty literal appears in any file (empty literals are
|
|
65
|
+
* skipped — a secret the run never set cannot leak).
|
|
66
|
+
*
|
|
67
|
+
* @param {object} params
|
|
68
|
+
* @param {string} params.dir
|
|
69
|
+
* @param {{label: string, literal: string}[]} params.secrets
|
|
70
|
+
* @param {import('@forwardimpact/libutil/runtime').Runtime} params.runtime
|
|
71
|
+
* @returns {Promise<string[]>} labels that hit
|
|
72
|
+
*/
|
|
73
|
+
export async function scanDirectory({ dir, secrets, runtime }) {
|
|
74
|
+
const files = await collectFiles(dir, runtime);
|
|
75
|
+
const contents = await Promise.all(
|
|
76
|
+
files.map((f) => runtime.fs.readFile(f, "utf8").catch(() => "")),
|
|
77
|
+
);
|
|
78
|
+
const failures = [];
|
|
79
|
+
for (const { label, literal } of secrets) {
|
|
80
|
+
if (!literal) continue;
|
|
81
|
+
if (contents.some((c) => c.includes(literal))) failures.push(label);
|
|
82
|
+
}
|
|
83
|
+
return failures;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve a directory of extracted log files, downloading the archive first
|
|
88
|
+
* when given a run id. Throws (→ fail closed) on any download or extract
|
|
89
|
+
* failure or on missing/invalid inputs.
|
|
90
|
+
*/
|
|
91
|
+
async function resolveLogsDir({ options, runtime }) {
|
|
92
|
+
const tmpRoot = runtime.proc.env.RUNNER_TEMP || "/tmp";
|
|
93
|
+
const dir = await runtime.fs.mkdtemp(join(tmpRoot, "scan-logs-"));
|
|
94
|
+
let zip = options.archive;
|
|
95
|
+
|
|
96
|
+
if (!zip) {
|
|
97
|
+
const runId = options["run-id"];
|
|
98
|
+
const repo = options.repo;
|
|
99
|
+
if (!runId || !repo) {
|
|
100
|
+
throw new Error("requires --archive, or --run-id and --repo");
|
|
101
|
+
}
|
|
102
|
+
if (!/^\d+$/.test(String(runId))) {
|
|
103
|
+
throw new Error("--run-id must be numeric");
|
|
104
|
+
}
|
|
105
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
|
|
106
|
+
throw new Error("--repo must be owner/name");
|
|
107
|
+
}
|
|
108
|
+
zip = join(dir, "run-logs.zip");
|
|
109
|
+
const dl = await runtime.subprocess.run("bash", [
|
|
110
|
+
"-c",
|
|
111
|
+
`gh api -H "Accept: application/vnd.github+json" ` +
|
|
112
|
+
`"/repos/${repo}/actions/runs/${runId}/logs" > "${zip}"`,
|
|
113
|
+
]);
|
|
114
|
+
if (dl.exitCode !== 0) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`log archive download failed (gh exit ${dl.exitCode}): ${dl.stderr ?? ""}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const unz = await runtime.subprocess.run("unzip", ["-q", zip, "-d", dir]);
|
|
122
|
+
if (unz.exitCode !== 0) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`log archive empty/unreadable (unzip exit ${unz.exitCode})`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return dir;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* scan-logs command handler.
|
|
132
|
+
*
|
|
133
|
+
* @param {import("@forwardimpact/libcli").InvocationContext} ctx
|
|
134
|
+
* @returns {Promise<{ok: boolean, code: number, error?: string}>}
|
|
135
|
+
*/
|
|
136
|
+
export async function runScanLogsCommand(ctx) {
|
|
137
|
+
const runtime = ctx.deps.runtime;
|
|
138
|
+
const options = ctx.options;
|
|
139
|
+
const secrets = parseSecrets(options.secret);
|
|
140
|
+
|
|
141
|
+
let dir;
|
|
142
|
+
try {
|
|
143
|
+
dir = await resolveLogsDir({ options, runtime });
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// Fail closed: an unresolvable archive must not pass as "no leak". The
|
|
146
|
+
// dispatcher prints the returned `error`, so don't also write it here.
|
|
147
|
+
return { ok: false, code: 1, error: `scan-logs: ${err.message}` };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const failures = await scanDirectory({ dir, secrets, runtime });
|
|
151
|
+
for (const label of failures) {
|
|
152
|
+
runtime.proc.stderr.write(`FAIL: ${label} literal in run logs\n`);
|
|
153
|
+
}
|
|
154
|
+
return { ok: failures.length === 0, code: failures.length ? 1 : 0 };
|
|
155
|
+
}
|