@bugb-technologies/bravos 0.0.0-stage → 1.0.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/LICENSE +70 -0
- package/README.md +225 -2
- package/dist/account.js +140 -0
- package/dist/artifacts.js +450 -0
- package/dist/ci.js +52 -0
- package/dist/cli.js +963 -0
- package/dist/codegraph.js +268 -0
- package/dist/cursor.js +70 -0
- package/dist/detect.js +26 -0
- package/dist/doctor.js +99 -0
- package/dist/global.js +95 -0
- package/dist/graphbin.js +136 -0
- package/dist/guardlink.js +123 -0
- package/dist/guide.js +255 -0
- package/dist/help.js +334 -0
- package/dist/hooks.js +167 -0
- package/dist/install.js +209 -0
- package/dist/licence.js +41 -0
- package/dist/lock.js +50 -0
- package/dist/mcp.js +139 -0
- package/dist/migrate.js +275 -0
- package/dist/omp.js +46 -0
- package/dist/opencode.js +118 -0
- package/dist/paths.js +33 -0
- package/dist/plugin.js +101 -0
- package/dist/report/check.js +252 -0
- package/dist/report/data.js +401 -0
- package/dist/report/export.js +105 -0
- package/dist/report/markdown.js +310 -0
- package/dist/report/meta.js +48 -0
- package/dist/report/pdf.js +96 -0
- package/dist/review-server.js +7 -0
- package/dist/review.js +274 -0
- package/dist/setup.js +121 -0
- package/dist/skills.js +81 -0
- package/dist/types.js +9 -0
- package/dist/uninstall.js +192 -0
- package/dist/versions.js +8 -0
- package/dist/watch-frame.js +593 -0
- package/dist/watch.js +873 -0
- package/dist/workspace.js +316 -0
- package/docs/GUIDE.md +323 -0
- package/hooks/autopilot.mjs +45 -0
- package/hooks/lib/autopilot.mjs +191 -0
- package/hooks/lib/briefing.mjs +750 -0
- package/hooks/lib/licence.mjs +452 -0
- package/hooks/lib/pre-pr.mjs +209 -0
- package/hooks/lib/sast.mjs +207 -0
- package/hooks/lib/setup.mjs +107 -0
- package/hooks/lib/workspace.mjs +78 -0
- package/hooks/pre-pr.mjs +54 -0
- package/hooks/session-start.mjs +82 -0
- package/package.json +49 -4
- package/skills/bravos/SKILL.md +63 -0
- package/skills/bravos-annotate/SKILL.md +56 -0
- package/skills/bravos-annotate/scripts/lib/pick.mjs +127 -0
- package/skills/bravos-annotate/scripts/slice.mjs +141 -0
- package/skills/bravos-campaign/SKILL.md +49 -0
- package/skills/bravos-cicd/SKILL.md +48 -0
- package/skills/bravos-codegraph/SKILL.md +57 -0
- package/skills/bravos-context/SKILL.md +51 -0
- package/skills/bravos-context/intent.example.md +48 -0
- package/skills/bravos-env/SKILL.md +48 -0
- package/skills/bravos-env/scope.example.yaml +50 -0
- package/skills/bravos-prove/SKILL.md +52 -0
- package/skills/bravos-recheck/SKILL.md +47 -0
- package/skills/bravos-report/SKILL.md +53 -0
- package/skills/bravos-report/assessment.md +54 -0
- package/skills/bravos-report/brief.md +23 -0
- package/skills/bravos-report/finding.md +74 -0
- package/skills/bravos-report/retest.md +29 -0
- package/skills/bravos-report/style.md +44 -0
- package/skills/bravos-sast/SKILL.md +48 -0
- package/skills/bravos-schedule/SKILL.md +44 -0
- package/skills/bravos-schedule/loop.md +16 -0
- package/skills/bravos-ship/SKILL.md +58 -0
- package/skills/bravos-templatize/SKILL.md +48 -0
- package/skills/bravos-templatize/operator-guidance.md +20 -0
- package/templates/omp/bravos.js +62 -0
- package/templates/opencode/bravos.js +33 -0
- package/templates/report/report.html +88 -0
- package/templates/review/review.html +327 -0
- package/templates/workflows/bravos-nightly-claude.yml +173 -0
- package/templates/workflows/bravos-nightly-codex.yml +172 -0
- package/templates/workflows/bravos-pr-gate.yml +142 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// @comment -- "Fetches, verifies and stages the two binary bundles bravos owns: the licensed analyzer (bravos-cli and its scanners) and the code graph (codegraph-build, codegraph-mcp). A port of the Python CLI's analyzer.py and artifact.py: each published file has a detached bravos-artifact-signature/1 statement signed under the artifact key (never the licence key), the manifest is verified before it is read, the tarball before it is opened, and the unpack refuses links, absolute paths, '..', duplicates and anything past a size ceiling. Nothing touches the network without a licence the portal accepts: the download URLs come from the account portal, which checks the licence server-side and presigns a short-lived link to the private bucket; BRAVOS_ARTIFACT_BASE_URL points at a plain directory instead, for tests and air-gapped mirrors."
|
|
2
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
3
|
+
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeSync } from 'node:fs';
|
|
4
|
+
import { arch, platform } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { gunzipSync } from 'node:zlib';
|
|
7
|
+
import { licenceLib } from './licence.js';
|
|
8
|
+
import { BravosError } from './types.js';
|
|
9
|
+
import { ANALYZER_VERSION, CODEGRAPH_VERSION } from './versions.js';
|
|
10
|
+
export const MAGIC = 'bravos-artifact-signature/1';
|
|
11
|
+
export const ARTIFACT_KID = 'bugb-artifact-2026a';
|
|
12
|
+
// @comment -- "The artifact signing key's public half, fixed in the source like the licence key and distinct from it: a licence says who may run the analyzer, a signature says these bytes are the ones Bugb published. No environment variable, flag or file adds a key; tests hand verify() a throwaway set in-process."
|
|
13
|
+
export const TRUSTED_ARTIFACT_KEYS = Object.freeze({
|
|
14
|
+
[ARTIFACT_KID]: '-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAX2x2o4eSFTU+Ti1Kkew+/t9LO4POT4D0r9aoRtJbz9o=\n-----END PUBLIC KEY-----\n',
|
|
15
|
+
});
|
|
16
|
+
export const MANIFEST_SCHEMAS = { analyzer: 'bravos-analyzer-manifest/2', codegraph: 'bravos-codegraph-manifest/1' };
|
|
17
|
+
export const BUNDLE_FILE = 'BUNDLE.json';
|
|
18
|
+
const MAX_MANIFEST_BYTES = 256 * 1024;
|
|
19
|
+
const MAX_SIGNATURE_BYTES = 4096;
|
|
20
|
+
const MAX_ARTIFACT_BYTES = 512 * 1024 * 1024;
|
|
21
|
+
const MAX_UNPACKED_BYTES = 1024 * 1024 * 1024;
|
|
22
|
+
const MAX_MEMBERS = 20_000;
|
|
23
|
+
export const BASE_URL_ENV = 'BRAVOS_ARTIFACT_BASE_URL';
|
|
24
|
+
export const NO_FETCH_ENV = 'BRAVOS_NO_FETCH';
|
|
25
|
+
export const TARGETS = ['aarch64-apple-darwin', 'x86_64-apple-darwin', 'x86_64-unknown-linux-gnu', 'aarch64-unknown-linux-gnu'];
|
|
26
|
+
export const REQUIRED = {
|
|
27
|
+
analyzer: ['bravos-cli', BUNDLE_FILE, 'rules/semgrep/PACK.toml', 'rules/sinks.yaml', 'rules/middleware.yaml', 'bin/gitleaks', 'bin/osv-scanner', 'bin/kics', 'bin/zizmor', 'share/kics/assets/queries', 'licences/NOTICE'],
|
|
28
|
+
codegraph: ['codegraph-build', 'codegraph-mcp', BUNDLE_FILE],
|
|
29
|
+
};
|
|
30
|
+
export const EXECUTABLES = {
|
|
31
|
+
analyzer: ['bravos-cli', 'bin/gitleaks', 'bin/osv-scanner', 'bin/kics', 'bin/zizmor'],
|
|
32
|
+
codegraph: ['codegraph-build', 'codegraph-mcp'],
|
|
33
|
+
};
|
|
34
|
+
export const VERSIONS = { analyzer: ANALYZER_VERSION, codegraph: CODEGRAPH_VERSION };
|
|
35
|
+
export function hostTarget() {
|
|
36
|
+
const p = platform();
|
|
37
|
+
const a = arch();
|
|
38
|
+
if (p === 'darwin')
|
|
39
|
+
return a === 'arm64' ? 'aarch64-apple-darwin' : a === 'x64' ? 'x86_64-apple-darwin' : null;
|
|
40
|
+
if (p === 'linux')
|
|
41
|
+
return a === 'x64' ? 'x86_64-unknown-linux-gnu' : a === 'arm64' ? 'aarch64-unknown-linux-gnu' : null;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const KID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
45
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
46
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
47
|
+
export class SignatureError extends Error {
|
|
48
|
+
}
|
|
49
|
+
/** The five header lines, as signed: the one place these bytes are assembled. */
|
|
50
|
+
export function signedStatement(kid, name, size, sha256) {
|
|
51
|
+
if (!KID_RE.test(kid))
|
|
52
|
+
throw new SignatureError(`malformed kid: ${JSON.stringify(kid)}`);
|
|
53
|
+
if (!NAME_RE.test(name))
|
|
54
|
+
throw new SignatureError(`malformed artifact name: ${JSON.stringify(name)}`);
|
|
55
|
+
if (!Number.isInteger(size) || size < 0)
|
|
56
|
+
throw new SignatureError(`malformed size: ${String(size)}`);
|
|
57
|
+
if (!HEX64.test(sha256))
|
|
58
|
+
throw new SignatureError(`malformed sha256: ${JSON.stringify(sha256)}`);
|
|
59
|
+
return Buffer.from(`${MAGIC}\nkid: ${kid}\nname: ${name}\nsize: ${size}\nsha256: ${sha256}\n`, 'utf8');
|
|
60
|
+
}
|
|
61
|
+
export function parseSignature(text) {
|
|
62
|
+
if (text.length > MAX_SIGNATURE_BYTES)
|
|
63
|
+
throw new SignatureError('signature file is far larger than a signature (is it HTML?)');
|
|
64
|
+
const lines = text.split('\n');
|
|
65
|
+
while (lines.length && lines[lines.length - 1] === '')
|
|
66
|
+
lines.pop();
|
|
67
|
+
if (lines.length !== 6)
|
|
68
|
+
throw new SignatureError(`expected 6 lines, found ${lines.length}`);
|
|
69
|
+
if (lines[0] !== MAGIC)
|
|
70
|
+
throw new SignatureError(`not a ${MAGIC} file: first line is ${JSON.stringify(lines[0])}`);
|
|
71
|
+
const fields = {};
|
|
72
|
+
for (const line of lines.slice(1)) {
|
|
73
|
+
const i = line.indexOf(': ');
|
|
74
|
+
if (i < 0)
|
|
75
|
+
throw new SignatureError(`malformed field line: ${JSON.stringify(line)}`);
|
|
76
|
+
const key = line.slice(0, i);
|
|
77
|
+
if (key in fields)
|
|
78
|
+
throw new SignatureError(`repeated field line: ${JSON.stringify(line)}`);
|
|
79
|
+
fields[key] = line.slice(i + 2);
|
|
80
|
+
}
|
|
81
|
+
const expected = ['kid', 'name', 'sha256', 'sig', 'size'];
|
|
82
|
+
if (Object.keys(fields).sort().join(',') !== expected.join(','))
|
|
83
|
+
throw new SignatureError(`fields are ${Object.keys(fields).sort().join(', ')}, expected ${expected.join(', ')}`);
|
|
84
|
+
if (!/^(0|[1-9]\d*)$/.test(fields.size))
|
|
85
|
+
throw new SignatureError(`size is not canonical: ${JSON.stringify(fields.size)}`);
|
|
86
|
+
const size = Number(fields.size);
|
|
87
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(fields.sig))
|
|
88
|
+
throw new SignatureError('sig is not valid base64');
|
|
89
|
+
const signature = Buffer.from(fields.sig, 'base64');
|
|
90
|
+
if (signature.length !== 64)
|
|
91
|
+
throw new SignatureError(`signature is ${signature.length} bytes, expected 64`);
|
|
92
|
+
const st = { kid: fields.kid, name: fields.name, size, sha256: fields.sha256, signature };
|
|
93
|
+
signedStatement(st.kid, st.name, st.size, st.sha256);
|
|
94
|
+
return st;
|
|
95
|
+
}
|
|
96
|
+
export const renderSignature = (st) => signedStatement(st.kid, st.name, st.size, st.sha256).toString('utf8') + 'sig: ' + st.signature.toString('base64') + '\n';
|
|
97
|
+
// @comment -- "Verifies `data` against its signature file: the digest and size are computed from the bytes on hand and the statement is rebuilt from them, so the signature can only ever vouch for what was actually downloaded, never for what the file said about itself."
|
|
98
|
+
export function verifyArtifact(name, data, sigText, keys = TRUSTED_ARTIFACT_KEYS) {
|
|
99
|
+
const st = parseSignature(sigText);
|
|
100
|
+
if (st.name !== name)
|
|
101
|
+
throw new SignatureError(`the signature is for ${JSON.stringify(st.name)}, not ${JSON.stringify(name)}`);
|
|
102
|
+
const sha256 = createHash('sha256').update(data).digest('hex');
|
|
103
|
+
if (st.size !== data.length || st.sha256 !== sha256)
|
|
104
|
+
throw new SignatureError(`${name} does not match its signature statement (size ${data.length} vs ${st.size}, sha256 ${sha256.slice(0, 12)}… vs ${st.sha256.slice(0, 12)}…)`);
|
|
105
|
+
const pem = keys[st.kid];
|
|
106
|
+
if (!pem)
|
|
107
|
+
throw new SignatureError(`unknown artifact signing key ${JSON.stringify(st.kid)}: either it is not ours, or this bravos predates a key rotation — upgrade bravos, then retry`);
|
|
108
|
+
let ok = false;
|
|
109
|
+
try {
|
|
110
|
+
ok = cryptoVerify(null, signedStatement(st.kid, st.name, st.size, st.sha256), createPublicKey({ key: pem, format: 'pem' }), st.signature);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
ok = false;
|
|
114
|
+
}
|
|
115
|
+
if (!ok)
|
|
116
|
+
throw new SignatureError(`${name} is not signed by Bugb (bad signature); nothing was installed. If this machine fetches through a proxy or a mirror, that is where to look`);
|
|
117
|
+
return st;
|
|
118
|
+
}
|
|
119
|
+
function* tarMembers(tar) {
|
|
120
|
+
let off = 0;
|
|
121
|
+
let longName = null;
|
|
122
|
+
let paxPath = null;
|
|
123
|
+
const str = (b) => b.toString('utf8').replace(/\0.*$/s, '');
|
|
124
|
+
while (off + 512 <= tar.length) {
|
|
125
|
+
const h = tar.subarray(off, off + 512);
|
|
126
|
+
if (h.every((b) => b === 0))
|
|
127
|
+
break;
|
|
128
|
+
const sizeField = str(h.subarray(124, 136)).trim();
|
|
129
|
+
const size = sizeField.charCodeAt(0) === 0x80 ? Number(BigInt('0x' + h.subarray(125, 136).toString('hex'))) : parseInt(sizeField || '0', 8);
|
|
130
|
+
if (!Number.isFinite(size) || size < 0)
|
|
131
|
+
throw new Error('tar: bad size field');
|
|
132
|
+
const type = String.fromCharCode(h[156]) || '0';
|
|
133
|
+
let name = str(h.subarray(0, 100));
|
|
134
|
+
const prefix = str(h.subarray(345, 500));
|
|
135
|
+
if (prefix && str(h.subarray(257, 263)) === 'ustar')
|
|
136
|
+
name = `${prefix}/${name}`;
|
|
137
|
+
const mode = parseInt(str(h.subarray(100, 108)).trim() || '0', 8);
|
|
138
|
+
const data = tar.subarray(off + 512, off + 512 + size);
|
|
139
|
+
if (data.length !== size)
|
|
140
|
+
throw new Error('tar: truncated member');
|
|
141
|
+
off += 512 + Math.ceil(size / 512) * 512;
|
|
142
|
+
if (type === 'L') {
|
|
143
|
+
longName = str(data);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (type === 'x') {
|
|
147
|
+
for (const rec of data.toString('utf8').split('\n')) {
|
|
148
|
+
const m = /^\d+ path=(.*)$/.exec(rec);
|
|
149
|
+
if (m)
|
|
150
|
+
paxPath = m[1];
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (type === 'g')
|
|
155
|
+
continue;
|
|
156
|
+
const finalName = paxPath ?? longName ?? name;
|
|
157
|
+
longName = null;
|
|
158
|
+
paxPath = null;
|
|
159
|
+
yield { name: finalName, size, mode, type: type === '\0' ? '0' : type, data };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function checkedParts(name) {
|
|
163
|
+
if (!name || name.includes('\0') || name.includes('\\') || name.startsWith('/'))
|
|
164
|
+
throw new Error(`${JSON.stringify(name)} is not a plain relative path`);
|
|
165
|
+
const parts = name.replace(/\/+$/, '').split('/');
|
|
166
|
+
if (parts.some((p) => p === '' || p === '.' || p === '..'))
|
|
167
|
+
throw new Error(`${JSON.stringify(name)} has an empty, '.' or '..' segment`);
|
|
168
|
+
return parts;
|
|
169
|
+
}
|
|
170
|
+
// @comment -- "Unpacks a verified .tar.gz into an empty folder with the Python CLI's rules: a gunzip ceiling, a member ceiling, plain relative paths only, regular files and directories only (a link is refused, and the tree is checked for links afterwards), each file created O_EXCL so nothing is overwritten, modes 0755 or 0644."
|
|
171
|
+
export function unpack(packed, into, name) {
|
|
172
|
+
let tar;
|
|
173
|
+
try {
|
|
174
|
+
tar = gunzipSync(packed, { maxOutputLength: MAX_UNPACKED_BYTES + 1 });
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
throw new BravosError(`${name} verified and will not decompress: ${e.message}. Nothing was installed`);
|
|
178
|
+
}
|
|
179
|
+
if (tar.length > MAX_UNPACKED_BYTES)
|
|
180
|
+
throw new BravosError(`${name} verified and expands past ${MAX_UNPACKED_BYTES} bytes. Nothing was installed`);
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
let count = 0;
|
|
183
|
+
try {
|
|
184
|
+
for (const m of tarMembers(tar)) {
|
|
185
|
+
if (++count > MAX_MEMBERS)
|
|
186
|
+
throw new Error(`holds more than ${MAX_MEMBERS} entries`);
|
|
187
|
+
const parts = checkedParts(m.name);
|
|
188
|
+
const key = parts.join('/');
|
|
189
|
+
if (seen.has(key))
|
|
190
|
+
throw new Error(`holds ${JSON.stringify(m.name)} twice`);
|
|
191
|
+
seen.add(key);
|
|
192
|
+
const target = join(into, ...parts);
|
|
193
|
+
if (m.type === '5') {
|
|
194
|
+
mkdirSync(target, { recursive: true, mode: 0o755 });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (m.type !== '0')
|
|
198
|
+
throw new Error(`holds ${JSON.stringify(m.name)}, which is not a regular file or a directory`);
|
|
199
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
200
|
+
const mode = m.mode & 0o100 ? 0o755 : 0o644;
|
|
201
|
+
const fd = openSync(target, 'wx', mode);
|
|
202
|
+
try {
|
|
203
|
+
writeSync(fd, m.data);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
closeSync(fd);
|
|
207
|
+
}
|
|
208
|
+
chmodSync(target, mode);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
throw new BravosError(`${name} verified and ${e.message}. Nothing was installed; report the bravos version and platform to Bugb support`);
|
|
213
|
+
}
|
|
214
|
+
const walk = (dir) => {
|
|
215
|
+
for (const n of readdirSync(dir)) {
|
|
216
|
+
const p = join(dir, n);
|
|
217
|
+
const st = lstatSync(p);
|
|
218
|
+
if (st.isSymbolicLink())
|
|
219
|
+
throw new BravosError(`${name} verified and left a link at ${p.slice(into.length + 1)}. Nothing was installed`);
|
|
220
|
+
if (st.isDirectory())
|
|
221
|
+
walk(p);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
walk(into);
|
|
225
|
+
}
|
|
226
|
+
// ---------------------------------------------------------------------------------------------
|
|
227
|
+
// Where things are staged
|
|
228
|
+
// ---------------------------------------------------------------------------------------------
|
|
229
|
+
export const homeDir = (env) => {
|
|
230
|
+
const h = env.BRAVOS_HOME || (env.HOME ? join(env.HOME, '.bravos') : null);
|
|
231
|
+
if (!h)
|
|
232
|
+
throw new BravosError('no home directory: set BRAVOS_HOME to where bravos should keep its binaries');
|
|
233
|
+
return h;
|
|
234
|
+
};
|
|
235
|
+
export const stageDir = (env, kind, version, target) => join(homeDir(env), kind, version, target);
|
|
236
|
+
export const currentLink = (env, kind) => join(homeDir(env), kind, 'current');
|
|
237
|
+
/** What is missing from a staged bundle, or [] when it is complete and executable. */
|
|
238
|
+
export function missingPaths(kind, root) {
|
|
239
|
+
const out = REQUIRED[kind].filter((p) => !existsSync(join(root, p)));
|
|
240
|
+
for (const p of EXECUTABLES[kind]) {
|
|
241
|
+
const abs = join(root, p);
|
|
242
|
+
if (existsSync(abs)) {
|
|
243
|
+
try {
|
|
244
|
+
if (!(lstatSync(abs).mode & 0o100))
|
|
245
|
+
out.push(`${p} (not executable)`);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
out.push(p);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
// @comment -- "The staged bundle this CLI would run: $BRAVOS_HOME/<kind>/current when it is complete, else the pinned version's folder for this host, else null."
|
|
255
|
+
export function staged(env, kind) {
|
|
256
|
+
let home;
|
|
257
|
+
try {
|
|
258
|
+
home = homeDir(env);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const candidates = [join(home, kind, 'current')];
|
|
264
|
+
const t = hostTarget();
|
|
265
|
+
if (t)
|
|
266
|
+
candidates.push(join(home, kind, VERSIONS[kind], t));
|
|
267
|
+
for (const c of candidates) {
|
|
268
|
+
try {
|
|
269
|
+
if (existsSync(c) && missingPaths(kind, c).length === 0)
|
|
270
|
+
return realpathSync(c);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
/* next */
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
/** The environment a staged bundle declares in BUNDLE.json: `env` (relative paths made absolute) and `path_prepend`. */
|
|
279
|
+
export function bundleEnvironment(root, base) {
|
|
280
|
+
const env = { ...base };
|
|
281
|
+
let doc = null;
|
|
282
|
+
try {
|
|
283
|
+
doc = JSON.parse(readFileSync(join(root, BUNDLE_FILE), 'utf8'));
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
doc = null;
|
|
287
|
+
}
|
|
288
|
+
const inside = (rel) => {
|
|
289
|
+
try {
|
|
290
|
+
const parts = checkedParts(rel);
|
|
291
|
+
return join(root, ...parts);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
if (doc && typeof doc === 'object') {
|
|
298
|
+
for (const [k, rel] of Object.entries(doc.env ?? {}).sort()) {
|
|
299
|
+
const p = typeof rel === 'string' ? inside(rel) : null;
|
|
300
|
+
if (p && !env[k])
|
|
301
|
+
env[k] = p;
|
|
302
|
+
}
|
|
303
|
+
const first = (Array.isArray(doc.path_prepend) ? doc.path_prepend : []).map((r) => (typeof r === 'string' ? inside(r) : null)).filter((p) => !!p);
|
|
304
|
+
if (first.length)
|
|
305
|
+
env.PATH = [...first, ...(env.PATH ? [env.PATH] : [])].join(':');
|
|
306
|
+
}
|
|
307
|
+
env.SEMGREP_ENABLE_VERSION_CHECK ??= '0';
|
|
308
|
+
env.SEMGREP_SEND_METRICS ??= 'off';
|
|
309
|
+
return env;
|
|
310
|
+
}
|
|
311
|
+
async function get(f, url, limit, what, headers = {}) {
|
|
312
|
+
let r;
|
|
313
|
+
try {
|
|
314
|
+
r = await f(url, { headers: { 'User-Agent': 'bravos-cli', ...headers }, redirect: 'follow' });
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
throw new BravosError(`could not fetch the ${what} (${url.split('?')[0]}): ${e.message}`);
|
|
318
|
+
}
|
|
319
|
+
// A presigned link carries its signature in the query; the message names the object, not the credential.
|
|
320
|
+
const where = url.split('?')[0];
|
|
321
|
+
if (r.status === 404)
|
|
322
|
+
throw new BravosError(`the ${what} is not published yet (${where}); run bravos setup again later — everything on the Free plan works without it`);
|
|
323
|
+
if (!r.ok)
|
|
324
|
+
throw new BravosError(`the ${what} answered HTTP ${r.status} (${where})`);
|
|
325
|
+
const len = Number(r.headers.get('content-length') || 0);
|
|
326
|
+
if (len > limit)
|
|
327
|
+
throw new BravosError(`the ${what} is ${len} bytes, past the ${limit}-byte ceiling (${url.split('?')[0]})`);
|
|
328
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
329
|
+
if (buf.length > limit)
|
|
330
|
+
throw new BravosError(`the ${what} is ${buf.length} bytes, past the ${limit}-byte ceiling (${url.split('?')[0]})`);
|
|
331
|
+
return buf;
|
|
332
|
+
}
|
|
333
|
+
// @comment -- "Where the files come from. With BRAVOS_ARTIFACT_BASE_URL, a plain layout <base>/<kind>/<version>/<file> (tests, mirrors). Otherwise the account portal: GET /api/v1/cli/artifacts/<kind>/<version>/<triple> with the licence as the bearer; the portal verifies it, counts the pull, and answers presigned URLs into the private bucket. The licence token goes to the portal origin and nowhere else; the presigned URLs carry their own authorization."
|
|
334
|
+
async function urlsFor(kind, version, target, io) {
|
|
335
|
+
const f = io.fetch ?? fetch;
|
|
336
|
+
const base = (io.env[BASE_URL_ENV] || '').trim();
|
|
337
|
+
if (base) {
|
|
338
|
+
const root = `${base.replace(/\/+$/, '')}/${kind}/${version}`;
|
|
339
|
+
return { manifest: `${root}/manifest.json`, manifestSig: `${root}/manifest.json.sig`, artifact: (file) => `${root}/${file}`, artifactSig: (file) => `${root}/${file}.sig` };
|
|
340
|
+
}
|
|
341
|
+
const L = await licenceLib();
|
|
342
|
+
const found = L.find(io.env);
|
|
343
|
+
if (!found.token)
|
|
344
|
+
throw new BravosError(`no licence at ${found.source}: the ${kind} download is licensed — bravos account activate (30-day Team trial, no card)`);
|
|
345
|
+
const originRaw = (io.env.BRAVOS_ACCOUNT_ORIGIN || '').trim();
|
|
346
|
+
const origin = originRaw || L.ACCOUNT_ORIGIN;
|
|
347
|
+
const url = `${origin}/api/v1/cli/artifacts/${kind}/${version}/${target}`;
|
|
348
|
+
let r;
|
|
349
|
+
try {
|
|
350
|
+
r = await f(url, { headers: { 'User-Agent': 'bravos-cli', Accept: 'application/json', Authorization: `Bearer ${found.token}` } });
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
throw new BravosError(`could not reach ${origin} for the ${kind} download: ${e.message}`);
|
|
354
|
+
}
|
|
355
|
+
if (r.status === 402)
|
|
356
|
+
throw new BravosError(`${origin} refused the ${kind} download: the licence from ${found.source} does not cover it (${L.ACCOUNT_UPGRADE})`);
|
|
357
|
+
if (r.status === 401)
|
|
358
|
+
throw new BravosError(`${origin} did not accept the licence from ${found.source}; bravos licence says why, ${L.ACCOUNT_LICENCES} has a fresh one`);
|
|
359
|
+
// 404: a portal that predates licensed downloads; 503: the portal has them but no bucket behind them yet. Both mean "not yet", not "broken".
|
|
360
|
+
if (r.status === 404 || r.status === 503)
|
|
361
|
+
throw new BravosError(`${origin} is not handing out the ${kind} download yet (HTTP ${r.status}); run bravos setup again later — everything on the Free plan works without it`);
|
|
362
|
+
if (!r.ok)
|
|
363
|
+
throw new BravosError(`${origin} answered HTTP ${r.status} for the ${kind} download`);
|
|
364
|
+
const body = (await r.json());
|
|
365
|
+
const need = ['url', 'sig_url', 'manifest_url', 'manifest_sig_url'];
|
|
366
|
+
for (const k of need)
|
|
367
|
+
if (typeof body[k] !== 'string' || !/^https?:\/\//.test(body[k]))
|
|
368
|
+
throw new BravosError(`${origin} answered without ${k}; report this to Bugb support`);
|
|
369
|
+
return { manifest: body.manifest_url, manifestSig: body.manifest_sig_url, artifact: () => body.url, artifactSig: () => body.sig_url };
|
|
370
|
+
}
|
|
371
|
+
// @comment -- "Fetch, verify and stage one bundle: manifest (+sig) → this host's artifact entry → tarball (+sig, size, sha256) → unpack into a temporary sibling folder → required paths present and executable → rename into place → the `current` link. Nothing under $BRAVOS_HOME changes until every check has passed, and BRAVOS_NO_FETCH refuses before the first request."
|
|
372
|
+
export async function install(kind, io, opts = {}) {
|
|
373
|
+
if (io.env[NO_FETCH_ENV])
|
|
374
|
+
throw new BravosError(`${NO_FETCH_ENV} is set: refusing to download the ${kind} bundle`);
|
|
375
|
+
const version = opts.version ?? VERSIONS[kind];
|
|
376
|
+
const target = opts.target ?? hostTarget();
|
|
377
|
+
if (!target)
|
|
378
|
+
throw new BravosError(`no ${kind} bundle is published for this platform (${platform()} ${arch()}); the targets are ${TARGETS.join(', ')}`);
|
|
379
|
+
const f = io.fetch ?? fetch;
|
|
380
|
+
const keys = io.keys ?? TRUSTED_ARTIFACT_KEYS;
|
|
381
|
+
const echo = io.echo ?? (() => { });
|
|
382
|
+
const urls = await urlsFor(kind, version, target, io);
|
|
383
|
+
echo(`${kind} ${version}: fetching the manifest`);
|
|
384
|
+
const manifestBytes = await get(f, urls.manifest, MAX_MANIFEST_BYTES, `${kind} manifest`);
|
|
385
|
+
const manifestSig = await get(f, urls.manifestSig, MAX_SIGNATURE_BYTES, `${kind} manifest signature`);
|
|
386
|
+
try {
|
|
387
|
+
verifyArtifact('manifest.json', manifestBytes, manifestSig.toString('utf8'), keys);
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
throw new BravosError(`the ${kind} manifest did not verify: ${e.message}`);
|
|
391
|
+
}
|
|
392
|
+
let manifest;
|
|
393
|
+
try {
|
|
394
|
+
manifest = JSON.parse(manifestBytes.toString('utf8'));
|
|
395
|
+
}
|
|
396
|
+
catch (e) {
|
|
397
|
+
throw new BravosError(`the signed ${kind} manifest is not JSON: ${e.message}; report this to Bugb support`);
|
|
398
|
+
}
|
|
399
|
+
if (manifest.schema !== MANIFEST_SCHEMAS[kind])
|
|
400
|
+
throw new BravosError(`the ${kind} manifest is ${JSON.stringify(manifest.schema)} and this bravos reads ${MANIFEST_SCHEMAS[kind]}: upgrade bravos, then retry`);
|
|
401
|
+
if (manifest.version !== version)
|
|
402
|
+
throw new BravosError(`the ${kind} manifest is version ${JSON.stringify(manifest.version)}, not ${version}`);
|
|
403
|
+
const entry = manifest.artifacts?.[target];
|
|
404
|
+
if (!entry || typeof entry.file !== 'string')
|
|
405
|
+
throw new BravosError(`no ${kind} ${version} build for ${target} in the manifest (it has ${Object.keys(manifest.artifacts ?? {}).join(', ') || 'none'})`);
|
|
406
|
+
const file = entry.file;
|
|
407
|
+
if (file !== file.split('/').pop() || file.startsWith('.'))
|
|
408
|
+
throw new BravosError(`the manifest names an artifact ${JSON.stringify(file)}; report this to Bugb support`);
|
|
409
|
+
echo(`${kind} ${version}: fetching ${file} (${Number(entry.size) ? `${Math.round(Number(entry.size) / 1048576)} MB` : 'size unknown'})`);
|
|
410
|
+
const packed = await get(f, urls.artifact(file), MAX_ARTIFACT_BYTES, `${kind} bundle`);
|
|
411
|
+
const sig = await get(f, urls.artifactSig(file), MAX_SIGNATURE_BYTES, `${kind} bundle signature`);
|
|
412
|
+
let st;
|
|
413
|
+
try {
|
|
414
|
+
st = verifyArtifact(file, packed, sig.toString('utf8'), keys);
|
|
415
|
+
}
|
|
416
|
+
catch (e) {
|
|
417
|
+
throw new BravosError(`${file} did not verify: ${e.message}`);
|
|
418
|
+
}
|
|
419
|
+
if ((typeof entry.sha256 === 'string' && entry.sha256 !== st.sha256) || (typeof entry.size === 'number' && entry.size !== st.size))
|
|
420
|
+
throw new BravosError(`${file} verified but is not the build the manifest names; report this to Bugb support`);
|
|
421
|
+
const dest = stageDir(io.env, kind, version, target);
|
|
422
|
+
const tmp = `${dest}.installing-${process.pid}`;
|
|
423
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
424
|
+
mkdirSync(tmp, { recursive: true, mode: 0o755 });
|
|
425
|
+
try {
|
|
426
|
+
echo(`${kind} ${version}: verified; unpacking`);
|
|
427
|
+
unpack(packed, tmp, file);
|
|
428
|
+
const missing = missingPaths(kind, tmp);
|
|
429
|
+
if (missing.length)
|
|
430
|
+
throw new BravosError(`${file} verified and unpacked, but the bundle lacks ${missing.join(', ')}. Nothing was installed; report this to Bugb support`);
|
|
431
|
+
rmSync(dest, { recursive: true, force: true });
|
|
432
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
433
|
+
renameSync(tmp, dest);
|
|
434
|
+
}
|
|
435
|
+
catch (e) {
|
|
436
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
437
|
+
throw e;
|
|
438
|
+
}
|
|
439
|
+
const link = currentLink(io.env, kind);
|
|
440
|
+
try {
|
|
441
|
+
if (lstatSync(link).isSymbolicLink() || existsSync(link))
|
|
442
|
+
unlinkSync(link);
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
/* absent */
|
|
446
|
+
}
|
|
447
|
+
symlinkSync(join(version, target), link, 'dir');
|
|
448
|
+
echo(`${kind} ${version}: staged at ${dest}`);
|
|
449
|
+
return { kind, version, target, dir: dest, manifest };
|
|
450
|
+
}
|
package/dist/ci.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { readLock, sha256, writeLock } from './lock.js';
|
|
4
|
+
import { templatesDir } from './paths.js';
|
|
5
|
+
// @comment -- "Token every bravos workflow carries in its first line, so an unlocked workflow can still be recognised as bravos-managed."
|
|
6
|
+
export const WORKFLOW_MARKER = 'bravos:generated';
|
|
7
|
+
// @comment -- "Copies each bundled templates/workflows/*.yml into .github/workflows/; an existing workflow is replaced only if bravos wrote it (lock hash or marker) unless --force, and the lock records each file written."
|
|
8
|
+
export function writeWorkflows(root, opts = {}) {
|
|
9
|
+
const src = join(opts.templates ?? templatesDir(), 'workflows');
|
|
10
|
+
const report = { written: [], updated: [], unchanged: [], skipped: [] };
|
|
11
|
+
if (!existsSync(src))
|
|
12
|
+
return report;
|
|
13
|
+
const lock = readLock(root);
|
|
14
|
+
const record = {};
|
|
15
|
+
for (const name of readdirSync(src).filter((n) => n.endsWith('.yml')).sort()) {
|
|
16
|
+
const path = `.github/workflows/${name}`;
|
|
17
|
+
const abs = join(root, path);
|
|
18
|
+
const content = readFileSync(join(src, name));
|
|
19
|
+
const hash = sha256(content);
|
|
20
|
+
let status = 'written';
|
|
21
|
+
if (existsSync(abs)) {
|
|
22
|
+
const disk = readFileSync(abs);
|
|
23
|
+
const diskHash = sha256(disk);
|
|
24
|
+
if (diskHash === hash) {
|
|
25
|
+
report.unchanged.push(path);
|
|
26
|
+
record[path] = hash;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const locked = lock.files[path];
|
|
30
|
+
if (locked !== undefined && diskHash !== locked && !opts.force) {
|
|
31
|
+
report.skipped.push({ path, reason: 'edited since last install (use --force to overwrite)' });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (locked === undefined && !disk.toString('utf8').includes(WORKFLOW_MARKER) && !opts.force) {
|
|
35
|
+
report.skipped.push({ path, reason: 'authored workflow, not managed by bravos (use --force to overwrite)' });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
status = 'updated';
|
|
39
|
+
}
|
|
40
|
+
report[status].push(path);
|
|
41
|
+
if (opts.dryRun)
|
|
42
|
+
continue;
|
|
43
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
44
|
+
writeFileSync(abs, content);
|
|
45
|
+
record[path] = hash;
|
|
46
|
+
}
|
|
47
|
+
if (!opts.dryRun && Object.keys(record).length > 0) {
|
|
48
|
+
const fresh = readLock(root);
|
|
49
|
+
writeLock(root, { ...fresh, files: { ...fresh.files, ...record } });
|
|
50
|
+
}
|
|
51
|
+
return report;
|
|
52
|
+
}
|