insta 0.0.1 → 0.0.3
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 +201 -0
- package/README.md +118 -0
- package/dist/api.js +99 -0
- package/dist/commands/auth.js +100 -0
- package/dist/commands/billing.js +52 -0
- package/dist/commands/branch.js +40 -0
- package/dist/commands/compute.js +45 -0
- package/dist/commands/deploy.js +44 -0
- package/dist/commands/govern.js +55 -0
- package/dist/commands/manifest.js +20 -0
- package/dist/commands/metrics.js +88 -0
- package/dist/commands/observe.js +58 -0
- package/dist/commands/org.js +16 -0
- package/dist/commands/project.js +61 -0
- package/dist/commands/secrets.js +36 -0
- package/dist/commands/services.js +89 -0
- package/dist/config.js +35 -0
- package/dist/ensure-skills.js +70 -0
- package/dist/flyctl-build.js +79 -0
- package/dist/index.js +132 -0
- package/dist/observe/hook.js +62 -0
- package/dist/observe/install.js +91 -0
- package/dist/observe/report.js +70 -0
- package/dist/observe/scanner.js +189 -0
- package/dist/util.js +57 -0
- package/package.json +28 -6
package/dist/index.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { ApiError } from './api.js';
|
|
5
|
+
import { die } from './util.js';
|
|
6
|
+
import * as auth from './commands/auth.js';
|
|
7
|
+
import * as org from './commands/org.js';
|
|
8
|
+
import * as project from './commands/project.js';
|
|
9
|
+
import * as branch from './commands/branch.js';
|
|
10
|
+
import * as services from './commands/services.js';
|
|
11
|
+
import * as secretsCmd from './commands/secrets.js';
|
|
12
|
+
import { deploy } from './commands/deploy.js';
|
|
13
|
+
import * as computeCmd from './commands/compute.js';
|
|
14
|
+
import { manifest } from './commands/manifest.js';
|
|
15
|
+
import * as govern from './commands/govern.js';
|
|
16
|
+
import * as observe from './commands/observe.js';
|
|
17
|
+
import * as obs from './commands/metrics.js';
|
|
18
|
+
import { billing, billingUpgrade, billingPortal } from './commands/billing.js';
|
|
19
|
+
function onError(e) {
|
|
20
|
+
if (e instanceof ApiError)
|
|
21
|
+
die(`${e.message} (HTTP ${e.status})`);
|
|
22
|
+
die(e instanceof Error ? e.message : String(e));
|
|
23
|
+
}
|
|
24
|
+
// Wrap an async action so rejections surface as clean CLI errors.
|
|
25
|
+
const guard = (fn) => (...a) => fn(...a).then(() => undefined).catch(onError);
|
|
26
|
+
const program = new Command();
|
|
27
|
+
// Version resolution: INSTA_CLI_VERSION (baked into the standalone binary via bun build --define) →
|
|
28
|
+
// the installed package.json (npm/node — ../package.json sits beside dist/) → 0.0.0.
|
|
29
|
+
function resolveVersion() {
|
|
30
|
+
if (process.env.INSTA_CLI_VERSION)
|
|
31
|
+
return process.env.INSTA_CLI_VERSION;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return '0.0.0';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version(resolveVersion());
|
|
40
|
+
// ---- auth ----
|
|
41
|
+
program.command('login').description('Log in with email + password, or --oauth <github|google> (browser)')
|
|
42
|
+
.option('--email <email>', 'account email')
|
|
43
|
+
.option('--password <password>', 'account password (else $INSTA_PASSWORD or prompt)')
|
|
44
|
+
.option('--oauth <provider>', 'browser OAuth login: github | google')
|
|
45
|
+
.option('--api-url <url>', 'control-plane API base URL')
|
|
46
|
+
.action(guard((o) => auth.login(o)));
|
|
47
|
+
program.command('logout').description('Log out and clear local tokens').action(guard(() => auth.logout()));
|
|
48
|
+
program.command('status').description('Show login + linked project').option('--json').action(guard((o) => auth.status(o)));
|
|
49
|
+
// ---- org ----
|
|
50
|
+
const orgCmd = program.command('org').description('Manage organizations');
|
|
51
|
+
orgCmd.command('list').option('--json').action(guard((o) => org.orgList(o)));
|
|
52
|
+
orgCmd.command('create <name>').action(guard((name) => org.orgCreate(name)));
|
|
53
|
+
// ---- project ----
|
|
54
|
+
const pj = program.command('project').description('Manage projects');
|
|
55
|
+
pj.command('create <name>').option('--org <id>', 'org to create under (default: personal)').action(guard((name, o) => project.projectCreate(name, o)));
|
|
56
|
+
pj.command('list').option('--org <id>').option('--json').action(guard((o) => project.projectList(o)));
|
|
57
|
+
pj.command('link <id>').description('Link a project to this directory').action(guard((id) => project.projectLink(id)));
|
|
58
|
+
pj.command('delete').option('--project <id>').action(guard((o) => project.projectDelete(o)));
|
|
59
|
+
// ---- branch ----
|
|
60
|
+
const br = program.command('branch').description('Manage branch environments');
|
|
61
|
+
br.command('create <name>').option('--from <branch>', 'parent branch (default: current)').action(guard((name, o) => branch.branchCreate(name, o)));
|
|
62
|
+
br.command('list').option('--json').action(guard((o) => branch.branchList(o)));
|
|
63
|
+
br.command('switch <name>').action(guard((name) => branch.branchSwitch(name)));
|
|
64
|
+
br.command('delete <name>').action(guard((name) => branch.branchDelete(name)));
|
|
65
|
+
// ---- services (opt-in postgres/storage/compute) ----
|
|
66
|
+
const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute)');
|
|
67
|
+
svc.command('add <type> <name>').description('Provision a service on demand (assigns a default domain for postgres/compute)')
|
|
68
|
+
.action(guard((type, name) => services.servicesAdd(type, name)));
|
|
69
|
+
svc.command('list').option('--json').action(guard((o) => services.servicesList(o)));
|
|
70
|
+
svc.command('remove <type> <name>').description('Remove a service and destroy its resources')
|
|
71
|
+
.action(guard((type, name) => services.servicesRemove(type, name)));
|
|
72
|
+
svc.command('scale <type> <name> <number> [region]').description('Set a compute service machine count (paid plans only)')
|
|
73
|
+
.option('--json').action(guard((type, name, number, region, o) => services.servicesScale(type, name, number, region, o)));
|
|
74
|
+
svc.command('upgrade <type> <name> <spec>').description('Change a compute/postgres service spec (paid plans only)')
|
|
75
|
+
.option('--json').action(guard((type, name, spec, o) => services.servicesUpgrade(type, name, spec, o)));
|
|
76
|
+
// ---- secrets (seam) ----
|
|
77
|
+
const sec = program.command('secrets').description('Fetch the credential bundle (secret seam) into .env')
|
|
78
|
+
.option('--branch <branch>').option('-o, --output <file>', 'output file (default .env)').option('--print', 'print instead of writing').option('--json')
|
|
79
|
+
.action(guard((o) => secretsCmd.secrets(o)));
|
|
80
|
+
sec.command('list').description('List secret names only').option('--branch <branch>').action(guard((o) => secretsCmd.secretsList(o)));
|
|
81
|
+
// ---- deploy ----
|
|
82
|
+
program.command('deploy [dir]').description('Deploy a source directory (built remotely on Fly) or a prebuilt --image to a branch compute group')
|
|
83
|
+
.option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>')
|
|
84
|
+
.action(guard((dir, o) => deploy(dir, o)));
|
|
85
|
+
// ---- compute custom domains (bring your own domain → Fly cert + routing) ----
|
|
86
|
+
const compute = program.command('compute').description('Compute custom domains (bring your own domain)');
|
|
87
|
+
compute.command('set-domain <host>').description('Attach a custom domain to a branch compute service (gated: deploy)')
|
|
88
|
+
.option('--branch <b>').option('--group <g>').option('--json').action(guard((host, o) => computeCmd.setDomain(host, o)));
|
|
89
|
+
compute.command('check-domain <host>').description("Show a custom domain's cert status + required DNS records")
|
|
90
|
+
.option('--branch <b>').option('--group <g>').option('--json').action(guard((host, o) => computeCmd.checkDomain(host, o)));
|
|
91
|
+
compute.command('remove-domain <host>').description('Detach a custom domain (gated: deploy)')
|
|
92
|
+
.option('--branch <b>').option('--group <g>').action(guard((host, o) => computeCmd.removeDomain(host, o)));
|
|
93
|
+
// ---- manifest ----
|
|
94
|
+
program.command('manifest').description('Print an agent-legible view of the project environments').option('--json').action(guard((o) => manifest(o)));
|
|
95
|
+
// ---- observability ----
|
|
96
|
+
program.command('metrics <target> [group]').description('Service metrics (target: db|compute)')
|
|
97
|
+
.option('--branch <b>').option('--from <unix>').option('--to <unix>').option('--step <s>').option('--json')
|
|
98
|
+
.action(guard((target, group, o) => obs.metrics(target, group, o)));
|
|
99
|
+
program.command('logs <target> [group]').description('Service runtime logs (target: db|compute)')
|
|
100
|
+
.option('--branch <b>').option('--limit <n>').option('--region <r>').option('--instance <i>').option('--json')
|
|
101
|
+
.action(guard((target, group, o) => obs.logs(target, group, o)));
|
|
102
|
+
program.command('usage').description('Usage for the current billing cycle by billing dimension (org by default; --proj for one project)')
|
|
103
|
+
.option('--from <unix>').option('--to <unix>').option('--proj [id]', 'show one project (the linked one, or a given id) instead of the whole org').option('--json')
|
|
104
|
+
.action(guard((o) => obs.usage(o)));
|
|
105
|
+
const bill = program.command('billing').description('Current billing cycle summary (tier / credit / used / overage)')
|
|
106
|
+
.option('--org <id>', 'target org (default: linked project\'s org)').option('--json')
|
|
107
|
+
.action(guard((o) => billing(o)));
|
|
108
|
+
bill.command('upgrade <tier>').description('Subscribe the org to a paid tier (pro|enterprise) via Stripe Checkout')
|
|
109
|
+
.option('--org <id>').option('--no-open', 'print the URL instead of opening a browser').option('--json')
|
|
110
|
+
.action(guard((tier, o) => billingUpgrade(tier, o)));
|
|
111
|
+
bill.command('portal').description('Open the Stripe Customer Portal (change plan / card / cancel)')
|
|
112
|
+
.option('--org <id>').option('--no-open', 'print the URL instead of opening a browser').option('--json')
|
|
113
|
+
.action(guard((o) => billingPortal(o)));
|
|
114
|
+
// ---- events (audit timeline) ----
|
|
115
|
+
program.command('events').description('Show the audit + agent-event timeline').option('--branch <b>').option('--limit <n>').option('--json').action(guard((o) => govern.events(o)));
|
|
116
|
+
// ---- approvals ----
|
|
117
|
+
const ap = program.command('approvals').description('Governance approvals (HITL)');
|
|
118
|
+
ap.command('list').option('--status <s>', 'pending|granted|denied|consumed').option('--json').action(guard((o) => govern.approvalsList(o)));
|
|
119
|
+
ap.command('approve <id>').option('--always', 'also set the policy to allow').action(guard((id, o) => govern.approvalsApprove(id, o)));
|
|
120
|
+
ap.command('deny <id>').action(guard((id) => govern.approvalsDeny(id)));
|
|
121
|
+
// ---- observe (local credential audit) ----
|
|
122
|
+
const ob = program.command('observe').description('Local credential-audit hook');
|
|
123
|
+
ob.command('install').description('Install the PostToolUse hook into this project').action(guard(() => observe.observeInstall()));
|
|
124
|
+
ob.command('uninstall').action(guard(() => observe.observeUninstall()));
|
|
125
|
+
ob.command('report').description('Render the local credential audit').option('--json').action(guard((o) => observe.observeReport(o)));
|
|
126
|
+
ob.command('sync').description('Upload findings into the project timeline').action(guard(() => observe.observeSync()));
|
|
127
|
+
// ---- policy ----
|
|
128
|
+
const pol = program.command('policy').description('Governance policy');
|
|
129
|
+
pol.command('get').option('--json').action(guard((o) => govern.policyGet(o)));
|
|
130
|
+
pol.command('set <action> <decision>').description('action: secrets.read|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade; decision: allow|deny|approve').action(guard((a, d) => govern.policySet(a, d)));
|
|
131
|
+
program.parseAsync(process.argv);
|
|
132
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// PostToolUse hook: reads a tool-use event on stdin (Claude Code / Codex), scans every string
|
|
2
|
+
// surface for credential exposure, and appends findings to ./.insta/audit.jsonl. Ported from firth.
|
|
3
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
import { scanEvent } from './scanner.js';
|
|
7
|
+
export function recordFindings(event, baseDir) {
|
|
8
|
+
const instaDir = join(baseDir, '.insta');
|
|
9
|
+
const instaAbs = resolve(instaDir);
|
|
10
|
+
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const ignore = (p) => {
|
|
12
|
+
let ap;
|
|
13
|
+
try {
|
|
14
|
+
ap = resolve(p);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
return ap.startsWith(instaAbs) || ap.startsWith(selfDir);
|
|
20
|
+
};
|
|
21
|
+
const findings = scanEvent(event, { ignorePath: ignore });
|
|
22
|
+
if (findings.length === 0)
|
|
23
|
+
return 0;
|
|
24
|
+
const common = {
|
|
25
|
+
ts: new Date().toISOString(),
|
|
26
|
+
session_id: event.session_id ?? null,
|
|
27
|
+
tool: event.tool_name ?? null,
|
|
28
|
+
cwd: event.cwd ?? null,
|
|
29
|
+
};
|
|
30
|
+
mkdirSync(instaDir, { recursive: true });
|
|
31
|
+
const lines = findings.map((f) => JSON.stringify({ ...common, ...f })).join('\n') + '\n';
|
|
32
|
+
appendFileSync(join(instaDir, 'audit.jsonl'), lines, 'utf8');
|
|
33
|
+
return findings.length;
|
|
34
|
+
}
|
|
35
|
+
async function readStdin() {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
for await (const c of process.stdin)
|
|
38
|
+
chunks.push(c);
|
|
39
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
40
|
+
}
|
|
41
|
+
export async function main() {
|
|
42
|
+
let event;
|
|
43
|
+
try {
|
|
44
|
+
event = JSON.parse(await readStdin());
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
const base = process.env.CLAUDE_PROJECT_DIR || event.cwd || '.';
|
|
50
|
+
try {
|
|
51
|
+
recordFindings(event, base);
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
process.stderr.write(`insta-observe: ${e instanceof Error ? e.message : e}\n`);
|
|
55
|
+
}
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
// Run only when executed directly (the materialized .insta/observe/hook.js entry).
|
|
59
|
+
// Imported by tests → guard is false → main() does not run.
|
|
60
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
61
|
+
void main();
|
|
62
|
+
//# sourceMappingURL=hook.js.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Install the observe hook into a project's agent harness (Claude Code / Codex) and materialize
|
|
2
|
+
// the standalone hook + scanner into ./.insta/observe. Ported from firth (.firth -> .insta).
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
const MARKER = 'insta-observe';
|
|
7
|
+
const DEFAULT_ASSET_DIR = dirname(fileURLToPath(import.meta.url)); // built: cli/dist/observe
|
|
8
|
+
function cliVersion() {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(readFileSync(join(DEFAULT_ASSET_DIR, '..', '..', 'package.json'), 'utf8')).version ?? '0.0.0';
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return '0.0.0';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function isInstaHook(h) {
|
|
17
|
+
if (h._insta === MARKER)
|
|
18
|
+
return true;
|
|
19
|
+
const blob = `${h.command ?? ''} ${(h.args ?? []).join(' ')}`;
|
|
20
|
+
return blob.includes('observe/hook.'); // matches .insta/observe/hook.js; trailing dot avoids matching user paths
|
|
21
|
+
}
|
|
22
|
+
function upsert(root, entry) {
|
|
23
|
+
root.hooks ??= {};
|
|
24
|
+
const post = Array.isArray(root.hooks.PostToolUse) ? root.hooks.PostToolUse : [];
|
|
25
|
+
for (const g of post)
|
|
26
|
+
g.hooks = (g.hooks ?? []).filter((h) => !isInstaHook(h));
|
|
27
|
+
const pruned = post.filter((g) => (g.hooks ?? []).length > 0);
|
|
28
|
+
pruned.push(entry);
|
|
29
|
+
root.hooks.PostToolUse = pruned;
|
|
30
|
+
}
|
|
31
|
+
function registerHarness(filePath, entry) {
|
|
32
|
+
const root = existsSync(filePath) ? JSON.parse(readFileSync(filePath, 'utf8')) : {};
|
|
33
|
+
upsert(root, entry);
|
|
34
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
35
|
+
writeFileSync(filePath, JSON.stringify(root, null, 2) + '\n');
|
|
36
|
+
}
|
|
37
|
+
function unregisterHarness(filePath) {
|
|
38
|
+
if (!existsSync(filePath))
|
|
39
|
+
return;
|
|
40
|
+
const root = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
41
|
+
const post = root?.hooks?.PostToolUse;
|
|
42
|
+
if (!Array.isArray(post))
|
|
43
|
+
return;
|
|
44
|
+
for (const g of post)
|
|
45
|
+
g.hooks = (g.hooks ?? []).filter((h) => !isInstaHook(h));
|
|
46
|
+
root.hooks.PostToolUse = post.filter((g) => (g.hooks ?? []).length > 0);
|
|
47
|
+
writeFileSync(filePath, JSON.stringify(root, null, 2) + '\n');
|
|
48
|
+
}
|
|
49
|
+
function materialize(cwd, assetDir) {
|
|
50
|
+
const dest = join(cwd, '.insta', 'observe');
|
|
51
|
+
mkdirSync(dest, { recursive: true });
|
|
52
|
+
copyFileSync(join(assetDir, 'hook.js'), join(dest, 'hook.js')); // throws if assets missing (e.g. not built)
|
|
53
|
+
copyFileSync(join(assetDir, 'scanner.js'), join(dest, 'scanner.js'));
|
|
54
|
+
writeFileSync(join(dest, 'package.json'), JSON.stringify({ type: 'module' }, null, 2) + '\n'); // run .js as ESM
|
|
55
|
+
writeFileSync(join(dest, 'VERSION'), cliVersion() + '\n');
|
|
56
|
+
}
|
|
57
|
+
function claudeEntry() {
|
|
58
|
+
return { matcher: '*', hooks: [{ type: 'command', command: 'node',
|
|
59
|
+
args: ['${CLAUDE_PROJECT_DIR}/.insta/observe/hook.js'], timeout: 15, _insta: MARKER }] };
|
|
60
|
+
}
|
|
61
|
+
function codexEntry(cwd) {
|
|
62
|
+
const abs = join(cwd, '.insta', 'observe', 'hook.js'); // Codex doesn't expand ${CLAUDE_PROJECT_DIR}; use an absolute path
|
|
63
|
+
return { matcher: '*', hooks: [{ type: 'command', command: `node ${JSON.stringify(abs)}`, timeout: 15, _insta: MARKER }] };
|
|
64
|
+
}
|
|
65
|
+
export function installObserve(opts) {
|
|
66
|
+
materialize(opts.cwd, opts.assetDir ?? DEFAULT_ASSET_DIR); // hook required → let a missing asset throw to the caller
|
|
67
|
+
let claude = false;
|
|
68
|
+
let codex = false;
|
|
69
|
+
try {
|
|
70
|
+
registerHarness(join(opts.cwd, '.claude', 'settings.json'), claudeEntry());
|
|
71
|
+
claude = true;
|
|
72
|
+
}
|
|
73
|
+
catch { /* skip malformed */ }
|
|
74
|
+
try {
|
|
75
|
+
registerHarness(join(opts.cwd, '.codex', 'hooks.json'), codexEntry(opts.cwd));
|
|
76
|
+
codex = true;
|
|
77
|
+
}
|
|
78
|
+
catch { /* skip malformed */ }
|
|
79
|
+
return { claude, codex };
|
|
80
|
+
}
|
|
81
|
+
export function uninstallObserve(cwd) {
|
|
82
|
+
try {
|
|
83
|
+
unregisterHarness(join(cwd, '.claude', 'settings.json'));
|
|
84
|
+
}
|
|
85
|
+
catch { /* skip */ }
|
|
86
|
+
try {
|
|
87
|
+
unregisterHarness(join(cwd, '.codex', 'hooks.json'));
|
|
88
|
+
}
|
|
89
|
+
catch { /* skip */ }
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=install.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const SEV_LABEL = { high: 'HIGH', warn: 'warn', info: 'info' };
|
|
2
|
+
const SINK_ORDER = ['network', 'git', 'nonsecret_file', 'stdout'];
|
|
3
|
+
export function renderReport(rows) {
|
|
4
|
+
if (rows.length === 0)
|
|
5
|
+
return 'Audit log is empty — nothing recorded yet (install the hook and let an agent run).';
|
|
6
|
+
const exposures = rows.filter((r) => r.kind === 'exposure');
|
|
7
|
+
const touches = rows.filter((r) => r.kind === 'touch');
|
|
8
|
+
const ts = rows.map((r) => String(r.ts ?? '')).filter(Boolean).sort();
|
|
9
|
+
const span = ts.length ? `${ts[0].slice(0, 19)} → ${ts[ts.length - 1].slice(0, 19)}` : 'unknown';
|
|
10
|
+
const uniqueSecrets = new Set(rows.map((r) => r.fingerprint));
|
|
11
|
+
const L = [];
|
|
12
|
+
L.push('='.repeat(64));
|
|
13
|
+
L.push(' InstaCloud Observe — what your agents did to your credentials');
|
|
14
|
+
L.push('='.repeat(64));
|
|
15
|
+
L.push(` window : ${span}`);
|
|
16
|
+
L.push(` events : ${rows.length} findings across ${uniqueSecrets.size} distinct secrets`);
|
|
17
|
+
L.push(` exposures : ${exposures.length} (secrets that left a safe place)`);
|
|
18
|
+
L.push(` touches : ${touches.length} (secrets the agent handled)`);
|
|
19
|
+
L.push('='.repeat(64));
|
|
20
|
+
if (exposures.length) {
|
|
21
|
+
L.push('\n⚠ EXPOSURES (look at these first)\n');
|
|
22
|
+
const bySink = new Map();
|
|
23
|
+
for (const r of exposures) {
|
|
24
|
+
const k = String(r.sink ?? '?');
|
|
25
|
+
const group = bySink.get(k);
|
|
26
|
+
if (group) {
|
|
27
|
+
group.push(r);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
bySink.set(k, [r]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const sinks = [...SINK_ORDER, ...[...bySink.keys()].filter((s) => !SINK_ORDER.includes(s))];
|
|
34
|
+
for (const sink of sinks) {
|
|
35
|
+
const group = bySink.get(sink);
|
|
36
|
+
if (!group)
|
|
37
|
+
continue;
|
|
38
|
+
L.push(` ┌─ sink: ${sink} (${group.length} finding(s))`);
|
|
39
|
+
for (const r of group) {
|
|
40
|
+
L.push(` │ [${SEV_LABEL[String(r.severity)] ?? '?'}] ${r.note ?? ''}`);
|
|
41
|
+
L.push(` │ secret : ${r.fingerprint}`);
|
|
42
|
+
L.push(` │ where : ${r.surface} (tool ${r.tool})`);
|
|
43
|
+
L.push(` │ when : ${String(r.ts ?? '').slice(0, 19)}`);
|
|
44
|
+
if (r.snippet)
|
|
45
|
+
L.push(` │ context: ${r.snippet}`);
|
|
46
|
+
}
|
|
47
|
+
L.push(' └─');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
L.push('\n✓ No exposures recorded.\n');
|
|
52
|
+
}
|
|
53
|
+
if (touches.length) {
|
|
54
|
+
L.push('\n· TOUCHES (informational)\n');
|
|
55
|
+
const counter = new Map();
|
|
56
|
+
for (const r of touches) {
|
|
57
|
+
const k = `${String(r.detector ?? '')}|||${String(r.note ?? '')}`;
|
|
58
|
+
counter.set(k, (counter.get(k) ?? 0) + 1);
|
|
59
|
+
}
|
|
60
|
+
for (const [k, count] of [...counter.entries()].sort((a, b) => b[1] - a[1])) {
|
|
61
|
+
const sep = k.indexOf('|||');
|
|
62
|
+
const detector = k.slice(0, sep);
|
|
63
|
+
const note = k.slice(sep + 3);
|
|
64
|
+
L.push(` ${String(count).padStart(3)}× ${detector} — ${note}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
L.push('\n(local audit only — nothing in this report has left your machine)');
|
|
68
|
+
return L.join('\n');
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const DETECTORS = [
|
|
3
|
+
{ name: 'aws_access_key_id', rx: /\b(?:AKIA|ASIA|AGPA|AIDA|AROA)[0-9A-Z]{16}\b/gd, group: false },
|
|
4
|
+
{ name: 'aws_secret_access_key', rx: /aws_secret_access_key\s*[:=]\s*['"]?([A-Za-z0-9/+]{40})/gid, group: true },
|
|
5
|
+
{ name: 'github_token', rx: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/gd, group: false },
|
|
6
|
+
{ name: 'github_pat', rx: /\bgithub_pat_[A-Za-z0-9_]{82}\b/gd, group: false },
|
|
7
|
+
{ name: 'slack_token', rx: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/gd, group: false },
|
|
8
|
+
{ name: 'stripe_secret_key', rx: /\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}\b/gd, group: false },
|
|
9
|
+
{ name: 'llm_api_key', rx: /\bsk-(?:ant-)?[A-Za-z0-9_-]{20,}\b/gd, group: false },
|
|
10
|
+
{ name: 'google_api_key', rx: /\bAIza[0-9A-Za-z_-]{35}\b/gd, group: false },
|
|
11
|
+
{ name: 'private_key_block', rx: /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/gd, group: false },
|
|
12
|
+
{ name: 'jwt', rx: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gd, group: false },
|
|
13
|
+
{ name: 'db_conn_string', rx: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s/@]+:[^@\s/]+@[^\s'"]+/gd, group: false },
|
|
14
|
+
{ name: 'bearer_token', rx: /\bbearer\s+([A-Za-z0-9._-]{20,})/gid, group: true },
|
|
15
|
+
{ name: 'generic_secret_assignment', rx: /\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key)\b\s*[:=]\s*['"]?([^\s'"]{8,})/gid, group: true },
|
|
16
|
+
];
|
|
17
|
+
const PLACEHOLDER = /^(?:your[_-]|example|changeme|placeholder|dummy|sample|redacted|secret|x{4,}|<.+>|\.\.\.|0{6,}|1234567|test[_-]?(?:key|token|secret))/i;
|
|
18
|
+
const REFERENCE = /(process\.env|os\.environ|getenv|import\.meta|\$\{|\$[A-Za-z_]|config\.|settings\.|^env\.|^[A-Z][A-Z0-9_]{3,}$)/;
|
|
19
|
+
// A captured value that is a code expression — a bare identifier followed by ≥1 member access,
|
|
20
|
+
// call, or index (authPassword.value, req.body.password, getSecret()) — is a reference, not a
|
|
21
|
+
// literal secret. A bare identifier with no suffix (e.g. hunter2pass) is NOT excused, so real
|
|
22
|
+
// unquoted/quoted literals still fire.
|
|
23
|
+
const CODE_REFERENCE = /^[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*|\([^)]*\)|\[[^\]]*\])+$/;
|
|
24
|
+
const SECRET_FILE = /(?:^|\/)(?:\.env(?:\.[A-Za-z0-9_]+)?|\.aws\/credentials|\.ssh\/id_(?:rsa|dsa|ecdsa|ed25519)|id_(?:rsa|dsa|ecdsa|ed25519)|\.npmrc|\.pypirc|\.netrc|\.git-credentials|\.kube\/config|kubeconfig|\.docker(?:cfg|\/config\.json)|credentials\.json|service-account[^/]*\.json|[^/]+\.(?:pem|key|p12|pfx|keystore|jks))$/i;
|
|
25
|
+
const SECRET_FILE_SAFE = /\.(?:example|sample|template|dist)$|\.pub$/i;
|
|
26
|
+
// apply_patch envelope markers (Codex). Format-based, so extraction is field-name-independent.
|
|
27
|
+
const PATCH_FILE = /^\*\*\* (?:Add|Update|Delete|Move) File: (.+?)\s*$/gim;
|
|
28
|
+
const NETWORK = /\b(?:curl|wget|httpie|http|nc|ncat|netcat|scp|sftp|ssh|telnet|rsync)\b/i;
|
|
29
|
+
const PRINT = /\b(?:echo|printf|cat|print|less|more|head|tail|xxd|base64|env|printenv|set)\b/i;
|
|
30
|
+
const GIT_WRITE = /\bgit\s+(?:add|commit|push|stash)\b/i;
|
|
31
|
+
export function isSecretFile(path) {
|
|
32
|
+
if (!path)
|
|
33
|
+
return false;
|
|
34
|
+
if (SECRET_FILE_SAFE.test(path))
|
|
35
|
+
return false;
|
|
36
|
+
return SECRET_FILE.test(path);
|
|
37
|
+
}
|
|
38
|
+
function fingerprint(secret, detector) {
|
|
39
|
+
const h = createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 8);
|
|
40
|
+
const last4 = secret.length >= 8 ? secret.slice(-4) : '';
|
|
41
|
+
const tail = last4 ? '••••' + last4 : '••••';
|
|
42
|
+
return `${detector}:${tail}:#${h}`;
|
|
43
|
+
}
|
|
44
|
+
function snippet(text, span, fp) {
|
|
45
|
+
const [s, e] = span;
|
|
46
|
+
const redacted = text.slice(0, s) + '«' + fp + '»' + text.slice(e);
|
|
47
|
+
const marker = '«' + fp + '»';
|
|
48
|
+
const i = redacted.indexOf(marker);
|
|
49
|
+
const start = Math.max(0, i - 36);
|
|
50
|
+
const end = Math.min(redacted.length, i + marker.length + 36);
|
|
51
|
+
let out = redacted.slice(start, end).replace(/\s+/g, ' ').trim();
|
|
52
|
+
if (start > 0)
|
|
53
|
+
out = '…' + out;
|
|
54
|
+
if (end < redacted.length)
|
|
55
|
+
out = out + '…';
|
|
56
|
+
return out.slice(0, 160);
|
|
57
|
+
}
|
|
58
|
+
function leaves(obj, prefix) {
|
|
59
|
+
const out = [];
|
|
60
|
+
const walk = (o, p) => {
|
|
61
|
+
if (typeof o === 'string')
|
|
62
|
+
out.push([p, o]);
|
|
63
|
+
else if (Array.isArray(o))
|
|
64
|
+
o.forEach((v, idx) => walk(v, `${p}[${idx}]`));
|
|
65
|
+
else if (o && typeof o === 'object')
|
|
66
|
+
for (const [k, v] of Object.entries(o))
|
|
67
|
+
walk(v, `${p}.${k}`);
|
|
68
|
+
};
|
|
69
|
+
walk(obj, prefix);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
function applyPatchPaths(ti) {
|
|
73
|
+
const paths = [];
|
|
74
|
+
for (const [, text] of leaves(ti, 'input')) {
|
|
75
|
+
PATCH_FILE.lastIndex = 0;
|
|
76
|
+
let m;
|
|
77
|
+
while ((m = PATCH_FILE.exec(text)) !== null) {
|
|
78
|
+
if (m[1])
|
|
79
|
+
paths.push(m[1]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return paths;
|
|
83
|
+
}
|
|
84
|
+
function scanText(text) {
|
|
85
|
+
const hits = [];
|
|
86
|
+
for (const { name, rx, group } of DETECTORS) {
|
|
87
|
+
rx.lastIndex = 0;
|
|
88
|
+
let m;
|
|
89
|
+
while ((m = rx.exec(text)) !== null) {
|
|
90
|
+
if (m.index === rx.lastIndex)
|
|
91
|
+
rx.lastIndex++; // zero-width guard
|
|
92
|
+
const gi = group ? 1 : 0;
|
|
93
|
+
const secret = m[gi];
|
|
94
|
+
const span = m.indices?.[gi];
|
|
95
|
+
if (!secret || !span)
|
|
96
|
+
continue;
|
|
97
|
+
if (PLACEHOLDER.test(secret))
|
|
98
|
+
continue;
|
|
99
|
+
if (name === 'generic_secret_assignment' && (REFERENCE.test(secret) || CODE_REFERENCE.test(secret)))
|
|
100
|
+
continue;
|
|
101
|
+
hits.push([span[0], span[1], name, secret]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
hits.sort((a, b) => a[0] - b[0] || a[1] - b[1]); // stable: earlier detector wins an equal span
|
|
105
|
+
const kept = [];
|
|
106
|
+
for (const r of hits) {
|
|
107
|
+
if (kept.some((k) => !(r[1] <= k[0] || r[0] >= k[1])))
|
|
108
|
+
continue;
|
|
109
|
+
kept.push(r);
|
|
110
|
+
}
|
|
111
|
+
return kept;
|
|
112
|
+
}
|
|
113
|
+
function classify(tool, side, command, filePath, secretFileTarget) {
|
|
114
|
+
if (['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch'].includes(tool) && side === 'input') {
|
|
115
|
+
if (secretFileTarget)
|
|
116
|
+
return ['touch', 'info', 'write_secret_file', `secret written to secret file ${filePath}`];
|
|
117
|
+
return ['exposure', 'high', 'nonsecret_file', `secret written into ${filePath || 'a non-secret file'}`];
|
|
118
|
+
}
|
|
119
|
+
if (tool === 'Bash' && side === 'input') {
|
|
120
|
+
if (NETWORK.test(command))
|
|
121
|
+
return ['exposure', 'high', 'network', 'secret in an outbound network command'];
|
|
122
|
+
if (GIT_WRITE.test(command))
|
|
123
|
+
return ['exposure', 'high', 'git', 'secret in a git write command'];
|
|
124
|
+
if (PRINT.test(command))
|
|
125
|
+
return ['exposure', 'warn', 'stdout', 'secret printed to stdout'];
|
|
126
|
+
return ['touch', 'info', 'shell', 'secret present in a shell command'];
|
|
127
|
+
}
|
|
128
|
+
if (side === 'output') {
|
|
129
|
+
if (tool === 'Bash')
|
|
130
|
+
return ['exposure', 'warn', 'stdout', 'secret appeared in command output'];
|
|
131
|
+
return ['touch', 'info', 'read', 'secret visible in tool output'];
|
|
132
|
+
}
|
|
133
|
+
return ['touch', 'info', 'other', 'secret handled by agent'];
|
|
134
|
+
}
|
|
135
|
+
export function scanEvent(event, opts = {}) {
|
|
136
|
+
const tool = event.tool_name || '?';
|
|
137
|
+
const ti = event.tool_input || {};
|
|
138
|
+
const tr = event.tool_response ?? {};
|
|
139
|
+
const filePath = ti.file_path || '';
|
|
140
|
+
const command = ti.command || '';
|
|
141
|
+
if (opts.ignorePath && filePath && opts.ignorePath(filePath))
|
|
142
|
+
return [];
|
|
143
|
+
const findings = [];
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
const add = (kind, sev, detector, surface, sink, fp, snip, note) => {
|
|
146
|
+
const key = [kind, detector, surface, fp, sink].join('|');
|
|
147
|
+
if (seen.has(key))
|
|
148
|
+
return;
|
|
149
|
+
seen.add(key);
|
|
150
|
+
findings.push({ kind, severity: sev, detector, surface, sink, fingerprint: fp, snippet: snip, note });
|
|
151
|
+
};
|
|
152
|
+
// Path-based rules (fire even without a value match).
|
|
153
|
+
if (isSecretFile(filePath)) {
|
|
154
|
+
const base = filePath.split('/').pop() ?? filePath;
|
|
155
|
+
if (tool === 'Read')
|
|
156
|
+
add('touch', 'info', 'secret_file', `${tool}.file_path`, 'read', `file:${base}`, filePath, `agent read secret file ${filePath}`);
|
|
157
|
+
else if (['Write', 'Edit', 'MultiEdit'].includes(tool))
|
|
158
|
+
add('touch', 'info', 'secret_file', `${tool}.file_path`, 'write_secret_file', `file:${base}`, filePath, `agent wrote secret file ${filePath}`);
|
|
159
|
+
}
|
|
160
|
+
if (command && GIT_WRITE.test(command)) {
|
|
161
|
+
for (const tok of command.match(/[^\s'"]+/g) ?? []) {
|
|
162
|
+
if (isSecretFile(tok)) {
|
|
163
|
+
const base = tok.split('/').pop() ?? tok;
|
|
164
|
+
add('exposure', 'high', 'secret_file', 'Bash.command', 'git', `file:${base}`, `git ... ${tok}`, `secret file ${tok} staged/committed via git`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Value-based rules: scan every string surface of input and output.
|
|
169
|
+
const patchPaths = tool === 'apply_patch' ? applyPatchPaths(ti) : [];
|
|
170
|
+
const secretFileTarget = isSecretFile(filePath) || patchPaths.some((p) => isSecretFile(p));
|
|
171
|
+
for (const p of patchPaths) {
|
|
172
|
+
if (isSecretFile(p)) {
|
|
173
|
+
const base = p.split('/').pop() ?? p;
|
|
174
|
+
add('touch', 'info', 'secret_file', 'apply_patch.input', 'write_secret_file', `file:${base}`, p, `agent wrote secret file ${p} via apply_patch`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const [surface, text] of [...leaves(ti, 'input'), ...leaves(tr, 'output')]) {
|
|
178
|
+
if (!text || text.length > 1_000_000)
|
|
179
|
+
continue;
|
|
180
|
+
const side = surface.startsWith('input') ? 'input' : 'output';
|
|
181
|
+
for (const [start, end, detector, secret] of scanText(text)) {
|
|
182
|
+
const fp = fingerprint(secret, detector);
|
|
183
|
+
const [kind, sev, sink, note] = classify(tool, side, command, filePath, secretFileTarget);
|
|
184
|
+
add(kind, sev, detector, `${tool}:${surface}`, sink, fp, snippet(text, [start, end], fp), note);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return findings;
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=scanner.js.map
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Output + small pure helpers (env serialization is unit-tested).
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
// Best-effort: open a URL in the user's default browser. Returns false if we couldn't launch.
|
|
5
|
+
export function openUrl(url) {
|
|
6
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
7
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
8
|
+
try {
|
|
9
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
10
|
+
child.on('error', () => { });
|
|
11
|
+
child.unref();
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function die(msg) {
|
|
19
|
+
process.stderr.write(`error: ${msg}\n`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
export function printJson(v) {
|
|
23
|
+
process.stdout.write(JSON.stringify(v, null, 2) + '\n');
|
|
24
|
+
}
|
|
25
|
+
export function info(msg) {
|
|
26
|
+
process.stdout.write(msg + '\n');
|
|
27
|
+
}
|
|
28
|
+
// If the platform gated the action (HTTP 202), tell the user how to get it approved. Returns
|
|
29
|
+
// true when an approval is pending (caller should stop).
|
|
30
|
+
export function handleApproval(res) {
|
|
31
|
+
if (res.status === 202 && res.body?.status === 'approval_required') {
|
|
32
|
+
info(`approval required for ${res.body.action} — run: insta approvals approve ${res.body.approvalId}`);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
// Serialize a credential bundle to .env text. All values are double-quoted (connection strings
|
|
38
|
+
// contain special chars); backslashes and quotes are escaped so dotenv parsers read them back exactly.
|
|
39
|
+
export function serializeEnv(bundle) {
|
|
40
|
+
return (Object.entries(bundle)
|
|
41
|
+
.map(([k, v]) => `${k}="${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`)
|
|
42
|
+
.join('\n') + '\n');
|
|
43
|
+
}
|
|
44
|
+
// Hidden password prompt (best-effort: mutes echo on a TTY).
|
|
45
|
+
export function promptPassword(label = 'Password: ') {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
48
|
+
const stdout = process.stdout;
|
|
49
|
+
process.stdout.write(label);
|
|
50
|
+
let captured = '';
|
|
51
|
+
stdout._writeToOutput = (s) => { if (s.includes('\n'))
|
|
52
|
+
process.stdout.write('\n'); };
|
|
53
|
+
rl.on('line', (line) => { captured = line; rl.close(); });
|
|
54
|
+
rl.on('close', () => resolve(captured));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=util.js.map
|
package/package.json
CHANGED
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "insta",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "InstaCloud CLI — a thin client of the platform control-plane API.",
|
|
6
|
+
"keywords": ["insta", "insforge", "cli", "backend", "baas", "platform"],
|
|
7
|
+
"license": "Apache-2.0",
|
|
8
|
+
"homepage": "https://github.com/InsForge/insta-cli#readme",
|
|
9
|
+
"repository": { "type": "git", "url": "git+https://github.com/InsForge/insta-cli.git" },
|
|
10
|
+
"bugs": { "url": "https://github.com/InsForge/insta-cli/issues" },
|
|
11
|
+
"bin": { "insta": "dist/index.js" },
|
|
12
|
+
"files": ["dist/**/*.js"],
|
|
13
|
+
"engines": { "node": ">=18" },
|
|
14
|
+
"publishConfig": { "access": "public" },
|
|
6
15
|
"scripts": {
|
|
7
|
-
"
|
|
16
|
+
"build": "tsc -p tsconfig.json",
|
|
17
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
18
|
+
"dev": "tsx src/index.ts",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"compile": "bun build ./src/index.ts --compile --minify --outfile dist/bin/insta",
|
|
21
|
+
"build:binaries": "bash scripts/build-binaries.sh",
|
|
22
|
+
"prepublishOnly": "npm run build"
|
|
8
23
|
},
|
|
9
|
-
"
|
|
10
|
-
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"commander": "^12.1.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^20.14.0",
|
|
29
|
+
"tsx": "^4.16.0",
|
|
30
|
+
"typescript": "^5.5.0",
|
|
31
|
+
"vitest": "^2.0.0"
|
|
32
|
+
}
|
|
11
33
|
}
|