@synkro-sh/cli 1.4.72 → 1.4.73

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/bootstrap.js CHANGED
@@ -6286,7 +6286,7 @@ function writeHookScripts() {
6286
6286
  /**
6287
6287
  * Local MCP guardrails server \u2014 runs on port 8931, stores rules in ~/.synkro/rules.json.
6288
6288
  * JSON-RPC 2.0 over HTTP, same protocol as the cloud MCP server.
6289
- * No auth (localhost only), no embedding API, no Inngest.
6289
+ * Bearer token auth (file-based shared secret), localhost only, no embedding API, no Inngest.
6290
6290
  */
6291
6291
  import { existsSync, readFileSync, writeFileSync, renameSync, appendFileSync, mkdirSync } from 'node:fs';
6292
6292
  import { homedir } from 'node:os';
@@ -6303,15 +6303,29 @@ const TOKEN_PATH = join(HOME, '.synkro', '.mcp-local-token');
6303
6303
  // File-based shared secret \u2014 generated once, required on all POST requests.
6304
6304
  function getOrCreateToken(): string {
6305
6305
  try {
6306
- if (existsSync(TOKEN_PATH)) return readFileSync(TOKEN_PATH, 'utf-8').trim();
6306
+ return readFileSync(TOKEN_PATH, 'utf-8').trim();
6307
6307
  } catch {}
6308
6308
  const token = randomBytes(32).toString('hex');
6309
6309
  mkdirSync(join(HOME, '.synkro'), { recursive: true });
6310
- writeFileSync(TOKEN_PATH, token + '\\n', { mode: 0o600 });
6311
- return token;
6310
+ try {
6311
+ writeFileSync(TOKEN_PATH, token + '\\n', { mode: 0o600, flag: 'wx' });
6312
+ return token;
6313
+ } catch {
6314
+ return readFileSync(TOKEN_PATH, 'utf-8').trim();
6315
+ }
6312
6316
  }
6313
6317
 
6314
6318
  const SERVER_TOKEN = getOrCreateToken();
6319
+ const MAX_BODY_BYTES = 1_048_576;
6320
+
6321
+ let _writeLock: Promise<void> = Promise.resolve();
6322
+ function serialized<T>(fn: () => T | Promise<T>): Promise<T> {
6323
+ let release: () => void;
6324
+ const next = new Promise<void>(r => { release = r; });
6325
+ const prev = _writeLock;
6326
+ _writeLock = next;
6327
+ return prev.then(() => fn()).finally(() => release!());
6328
+ }
6315
6329
 
6316
6330
  // \u2500\u2500\u2500 Storage \u2500\u2500\u2500
6317
6331
 
@@ -6698,6 +6712,219 @@ function handleListExemptions(): any {
6698
6712
  return { exemptions: data.scanExemptions, total: data.scanExemptions.length };
6699
6713
  }
6700
6714
 
6715
+ // \u2500\u2500\u2500 Findings \u2500\u2500\u2500
6716
+
6717
+ const CONFIG_PATH = join(HOME, '.synkro', 'config.json');
6718
+ const JWT_PATH = join(HOME, '.synkro', '.jwt');
6719
+
6720
+ interface Finding {
6721
+ id: string;
6722
+ session_id: string;
6723
+ file_path: string;
6724
+ finding_type: string;
6725
+ finding_id: string;
6726
+ severity: string;
6727
+ status: string;
6728
+ detail?: string;
6729
+ package_name?: string;
6730
+ package_version?: string;
6731
+ fixed_version?: string;
6732
+ created_at: string;
6733
+ resolved_at?: string;
6734
+ }
6735
+
6736
+ function getCloudConfig(): { apiUrl: string; jwt: string } | null {
6737
+ try {
6738
+ const jwt = readFileSync(JWT_PATH, 'utf-8').trim();
6739
+ if (!jwt) return null;
6740
+ let apiUrl = process.env.SYNKRO_API_URL || '';
6741
+ if (!apiUrl) {
6742
+ try {
6743
+ const cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
6744
+ apiUrl = cfg.api_url || cfg.apiUrl || '';
6745
+ } catch {}
6746
+ }
6747
+ if (!apiUrl) apiUrl = 'https://api.synkro.sh';
6748
+ return { apiUrl, jwt };
6749
+ } catch {
6750
+ return null;
6751
+ }
6752
+ }
6753
+
6754
+ function readLocalFindings(): Finding[] {
6755
+ if (!existsSync(TELEMETRY_PATH)) return [];
6756
+ let lines: string[];
6757
+ try {
6758
+ lines = readFileSync(TELEMETRY_PATH, 'utf-8').split('\\n').filter(Boolean);
6759
+ } catch { return []; }
6760
+ const findingMap = new Map<string, Finding>();
6761
+
6762
+ for (const line of lines) {
6763
+ try {
6764
+ const event = JSON.parse(line);
6765
+ if (event.capture_type !== 'scan_finding') continue;
6766
+ const key = \`\${event.file_path}:\${event.finding_id}\`;
6767
+ const existing = findingMap.get(key);
6768
+ const ts = event._ts || event.created_at || '';
6769
+
6770
+ findingMap.set(key, {
6771
+ id: event.id || existing?.id || \`sf_\${event.session_id}_\${event.finding_id}_\${Date.now()}\`,
6772
+ session_id: event.session_id || existing?.session_id || '',
6773
+ file_path: event.file_path,
6774
+ finding_type: event.finding_type,
6775
+ finding_id: event.finding_id,
6776
+ severity: event.severity || 'unknown',
6777
+ status: event.status || 'open',
6778
+ detail: event.detail || existing?.detail,
6779
+ package_name: event.package_name || existing?.package_name,
6780
+ package_version: event.package_version || existing?.package_version,
6781
+ fixed_version: event.fixed_version || existing?.fixed_version,
6782
+ created_at: existing?.created_at || ts,
6783
+ resolved_at: event.status === 'resolved' ? ts : existing?.resolved_at,
6784
+ });
6785
+ } catch {}
6786
+ }
6787
+
6788
+ return Array.from(findingMap.values());
6789
+ }
6790
+
6791
+ async function fetchCloudFindings(params: Record<string, string>): Promise<Finding[] | null> {
6792
+ const cloud = getCloudConfig();
6793
+ if (!cloud) return null;
6794
+ try {
6795
+ const qs = new URLSearchParams(params).toString();
6796
+ const resp = await fetch(\`\${cloud.apiUrl}/api/v1/scan-findings?\${qs}\`, {
6797
+ headers: { Authorization: \`Bearer \${cloud.jwt}\` },
6798
+ signal: AbortSignal.timeout(5000),
6799
+ });
6800
+ if (!resp.ok) return null;
6801
+ const data = await resp.json() as any;
6802
+ return data.findings || data.data || [];
6803
+ } catch {
6804
+ return null;
6805
+ }
6806
+ }
6807
+
6808
+ function dispatchResolution(finding: Finding): void {
6809
+ const cloud = getCloudConfig();
6810
+ if (!cloud) return;
6811
+ fetch(\`\${cloud.apiUrl}/api/v1/hook/finding\`, {
6812
+ method: 'POST',
6813
+ headers: { Authorization: \`Bearer \${cloud.jwt}\`, 'Content-Type': 'application/json' },
6814
+ body: JSON.stringify({
6815
+ session_id: finding.session_id,
6816
+ file_path: finding.file_path,
6817
+ finding_type: finding.finding_type,
6818
+ finding_id: finding.finding_id,
6819
+ severity: finding.severity,
6820
+ status: 'resolved',
6821
+ }),
6822
+ signal: AbortSignal.timeout(3000),
6823
+ }).catch(() => {});
6824
+ }
6825
+
6826
+ async function handleListFindings(args: any): Promise<any> {
6827
+ const cloudParams: Record<string, string> = {};
6828
+ if (args.status) cloudParams.status = args.status;
6829
+ if (args.finding_type) cloudParams.finding_type = args.finding_type;
6830
+ if (args.severity) cloudParams.severity = args.severity;
6831
+ if (args.file_path) cloudParams.file_path = args.file_path;
6832
+ if (args.limit) cloudParams.limit = String(args.limit);
6833
+
6834
+ const cloudFindings = await fetchCloudFindings(cloudParams);
6835
+
6836
+ let findings: Finding[];
6837
+ let source: string;
6838
+ if (cloudFindings) {
6839
+ findings = cloudFindings;
6840
+ source = 'cloud';
6841
+ } else {
6842
+ findings = readLocalFindings();
6843
+ source = 'local';
6844
+ if (args.status) findings = findings.filter(f => f.status === args.status);
6845
+ if (args.finding_type) findings = findings.filter(f => f.finding_type === args.finding_type);
6846
+ if (args.severity) findings = findings.filter(f => f.severity === args.severity);
6847
+ if (args.file_path) findings = findings.filter(f => f.file_path.includes(args.file_path));
6848
+ }
6849
+
6850
+ findings.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
6851
+ const limit = Math.min(args.limit || 25, 50);
6852
+ return {
6853
+ source,
6854
+ findings: findings.slice(0, limit).map(f => ({
6855
+ id: f.id,
6856
+ finding_type: f.finding_type,
6857
+ finding_id: f.finding_id,
6858
+ file_path: f.file_path,
6859
+ severity: f.severity,
6860
+ status: f.status,
6861
+ package_name: f.package_name,
6862
+ package_version: f.package_version,
6863
+ created_at: f.created_at,
6864
+ })),
6865
+ total: findings.length,
6866
+ open: findings.filter(f => f.status === 'open').length,
6867
+ resolved: findings.filter(f => f.status === 'resolved').length,
6868
+ };
6869
+ }
6870
+
6871
+ async function handleGetFindingDetail(args: any): Promise<any> {
6872
+ const findings = readLocalFindings();
6873
+ const match = findings.find(f => {
6874
+ if (args.id && f.id === args.id) return true;
6875
+ if (args.file_path && args.finding_id) {
6876
+ return f.file_path.includes(args.file_path) && f.finding_id.toUpperCase() === (args.finding_id || '').toUpperCase();
6877
+ }
6878
+ return false;
6879
+ });
6880
+ if (!match) return { found: false, error: 'Finding not found' };
6881
+ return { found: true, ...match };
6882
+ }
6883
+
6884
+ async function handleResolveFinding(args: any): Promise<any> {
6885
+ const findings = readLocalFindings();
6886
+ const toResolve = findings.filter(f => {
6887
+ if (f.status !== 'open') return false;
6888
+ if (args.id && f.id === args.id) return true;
6889
+ if (args.file_path && args.finding_id) {
6890
+ return f.file_path.includes(args.file_path) && f.finding_id.toUpperCase() === (args.finding_id || '').toUpperCase();
6891
+ }
6892
+ if (args.file_path) return f.file_path.includes(args.file_path);
6893
+ return false;
6894
+ });
6895
+
6896
+ if (toResolve.length === 0) return { resolved: 0, error: 'No matching open findings' };
6897
+
6898
+ const now = new Date().toISOString();
6899
+ for (const f of toResolve) {
6900
+ const event = {
6901
+ capture_type: 'scan_finding',
6902
+ id: f.id,
6903
+ session_id: f.session_id,
6904
+ file_path: f.file_path,
6905
+ finding_type: f.finding_type,
6906
+ finding_id: f.finding_id,
6907
+ severity: f.severity,
6908
+ status: 'resolved',
6909
+ detail: f.detail,
6910
+ package_name: f.package_name,
6911
+ package_version: f.package_version,
6912
+ fixed_version: f.fixed_version,
6913
+ resolved_at: now,
6914
+ _ts: now,
6915
+ };
6916
+ try {
6917
+ appendFileSync(TELEMETRY_PATH, JSON.stringify(event) + '\\n', 'utf-8');
6918
+ } catch {}
6919
+ dispatchResolution(f);
6920
+ }
6921
+
6922
+ return {
6923
+ resolved: toResolve.length,
6924
+ findings: toResolve.map(f => ({ finding_id: f.finding_id, file_path: f.file_path })),
6925
+ };
6926
+ }
6927
+
6701
6928
  // \u2500\u2500\u2500 Tool Descriptors \u2500\u2500\u2500
6702
6929
 
6703
6930
  const TOOL_DESCRIPTORS = [
@@ -6861,6 +7088,47 @@ const TOOL_DESCRIPTORS = [
6861
7088
  description: "List all scan exemptions.",
6862
7089
  inputSchema: { type: 'object', properties: {}, required: [] },
6863
7090
  },
7091
+ {
7092
+ name: 'list_findings',
7093
+ description: "List CWE/CVE scan findings. Shows security issues found by Synkro hooks. Use to review what needs fixing.",
7094
+ inputSchema: {
7095
+ type: 'object',
7096
+ properties: {
7097
+ status: { type: 'string', enum: ['open', 'resolved', 'exempted'], description: 'Filter by status (default: all).' },
7098
+ finding_type: { type: 'string', enum: ['cwe', 'cve'], description: 'Filter by finding type.' },
7099
+ severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
7100
+ file_path: { type: 'string', description: 'Filter by file path substring.' },
7101
+ limit: { type: 'integer', default: 25, description: 'Max results (default 25, max 50).' },
7102
+ },
7103
+ required: [],
7104
+ },
7105
+ },
7106
+ {
7107
+ name: 'get_finding_detail',
7108
+ description: "Get full detail of a specific finding including remediation context.",
7109
+ inputSchema: {
7110
+ type: 'object',
7111
+ properties: {
7112
+ id: { type: 'string', description: 'Finding ID (e.g. sf_...).' },
7113
+ file_path: { type: 'string', description: 'File path (used with finding_id).' },
7114
+ finding_id: { type: 'string', description: 'CWE/CVE ID like CWE-89 or CVE-2024-1234.' },
7115
+ },
7116
+ required: [],
7117
+ },
7118
+ },
7119
+ {
7120
+ name: 'resolve_finding',
7121
+ description: "Mark finding(s) as resolved after the underlying issue is fixed. Can target by ID, file+finding_id, or all findings for a file.",
7122
+ inputSchema: {
7123
+ type: 'object',
7124
+ properties: {
7125
+ id: { type: 'string', description: 'Specific finding ID to resolve.' },
7126
+ file_path: { type: 'string', description: 'Resolve all open findings matching this file path.' },
7127
+ finding_id: { type: 'string', description: 'CWE/CVE ID (used with file_path for targeted resolution).' },
7128
+ },
7129
+ required: [],
7130
+ },
7131
+ },
6864
7132
  ];
6865
7133
 
6866
7134
  const MCP_INSTRUCTIONS =
@@ -6871,7 +7139,12 @@ const MCP_INSTRUCTIONS =
6871
7139
  "'we need a rule about\u2026' \u2014 route to THIS server's tools.\\n\\n" +
6872
7140
  "TOOL ROUTING:\\n" +
6873
7141
  " \u2022 get_guardrails(query) \u2014 keyword search. Use to check if a rule exists.\\n" +
6874
- " \u2022 list_guardrails \u2014 full enumeration. Use for listings.\\n\\n" +
7142
+ " \u2022 list_guardrails \u2014 full enumeration. Use for listings.\\n" +
7143
+ " \u2022 list_findings \u2014 show CWE/CVE scan findings (open, resolved, all).\\n" +
7144
+ " \u2022 get_finding_detail \u2014 get full detail + remediation context for a finding.\\n" +
7145
+ " \u2022 resolve_finding \u2014 mark findings resolved after fixing the code.\\n\\n" +
7146
+ "When the user asks about security issues, vulnerabilities, or scan results, " +
7147
+ "use list_findings first. After fixing code, call resolve_finding to update status.\\n\\n" +
6875
7148
  "Do NOT use Claude Code's \`update-config\` skill for these requests.\\n\\n" +
6876
7149
  "Rules are stored locally in ~/.synkro/rules.json and enforced by hooks.";
6877
7150
 
@@ -6924,6 +7197,9 @@ async function handleRpc(body: any): Promise<any> {
6924
7197
  case 'exempt_path': result = handleExemptPath(args); break;
6925
7198
  case 'remove_exemption': result = handleRemoveExemption(args); break;
6926
7199
  case 'list_exemptions': result = handleListExemptions(); break;
7200
+ case 'list_findings': result = await handleListFindings(args); break;
7201
+ case 'get_finding_detail': result = await handleGetFindingDetail(args); break;
7202
+ case 'resolve_finding': result = await handleResolveFinding(args); break;
6927
7203
  default: return jsonRpcError(id, -32601, \`Unknown tool: \${toolName}\`);
6928
7204
  }
6929
7205
  return jsonRpcOk(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
@@ -7063,14 +7339,25 @@ const server = Bun.serve({
7063
7339
  }
7064
7340
 
7065
7341
  if (req.method === 'POST') {
7066
- try {
7067
- const body = await req.json();
7068
- const result = await handleRpc(body);
7069
- if (result === null) return new Response('', { status: 204, headers: cors });
7070
- return Response.json(result, { headers: cors });
7071
- } catch (err) {
7072
- return Response.json(jsonRpcError(null, -32700, 'Parse error'), { status: 400, headers: cors });
7342
+ const authHeader = req.headers.get('authorization') || '';
7343
+ const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
7344
+ if (bearer !== SERVER_TOKEN) {
7345
+ return Response.json({ error: 'Unauthorized' }, { status: 401, headers: cors });
7346
+ }
7347
+ const raw = await req.arrayBuffer();
7348
+ if (raw.byteLength > MAX_BODY_BYTES) {
7349
+ return Response.json(jsonRpcError(null, -32600, 'Request too large'), { status: 413, headers: cors });
7073
7350
  }
7351
+ return serialized(async () => {
7352
+ try {
7353
+ const body = JSON.parse(new TextDecoder().decode(raw));
7354
+ const result = await handleRpc(body);
7355
+ if (result === null) return new Response('', { status: 204, headers: cors });
7356
+ return Response.json(result, { headers: cors });
7357
+ } catch {
7358
+ return Response.json(jsonRpcError(null, -32700, 'Parse error'), { status: 400, headers: cors });
7359
+ }
7360
+ });
7074
7361
  }
7075
7362
 
7076
7363
  if (req.method === 'OPTIONS') {
@@ -7145,7 +7432,7 @@ function writeConfigEnv(opts) {
7145
7432
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
7146
7433
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
7147
7434
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
7148
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.72")}`
7435
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.73")}`
7149
7436
  ];
7150
7437
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
7151
7438
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);