@synkro-sh/cli 1.3.24 → 1.3.26

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
@@ -2508,7 +2508,7 @@ jobs:
2508
2508
  scan:
2509
2509
  runs-on: ubuntu-latest
2510
2510
  permissions:
2511
- contents: read
2511
+ contents: write
2512
2512
  pull-requests: write
2513
2513
  checks: write
2514
2514
  steps:
@@ -3333,7 +3333,7 @@ function writeConfigEnv(opts) {
3333
3333
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
3334
3334
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
3335
3335
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
3336
- `SYNKRO_VERSION=${shellQuoteSingle("1.3.24")}`
3336
+ `SYNKRO_VERSION=${shellQuoteSingle("1.3.26")}`
3337
3337
  ];
3338
3338
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
3339
3339
  if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
@@ -4374,6 +4374,60 @@ function ghJson(args2) {
4374
4374
  });
4375
4375
  return JSON.parse(out);
4376
4376
  }
4377
+ async function ensureOpenPr(repo, prNumber, sha) {
4378
+ let pr;
4379
+ try {
4380
+ pr = ghJson(["api", `/repos/${repo}/pulls/${prNumber}`]);
4381
+ } catch {
4382
+ return { prNumber, sha, branched: false };
4383
+ }
4384
+ if (pr.state === "open") {
4385
+ return { prNumber, sha, branched: false };
4386
+ }
4387
+ if (pr.merged) {
4388
+ console.log(`PR #${prNumber} is merged. Scanning original diff and posting findings there.
4389
+ `);
4390
+ return { prNumber, sha: pr.head.sha, branched: false };
4391
+ }
4392
+ console.log(`PR #${prNumber} is closed. Creating a scan branch...
4393
+ `);
4394
+ const scanBranch = `synkro/scan-${pr.head.ref}-${Date.now()}`;
4395
+ try {
4396
+ execSync5(`gh api -X POST /repos/${repo}/git/refs --input -`, {
4397
+ encoding: "utf-8",
4398
+ input: JSON.stringify({ ref: `refs/heads/${scanBranch}`, sha: pr.head.sha }),
4399
+ stdio: ["pipe", "pipe", "pipe"]
4400
+ });
4401
+ } catch (err) {
4402
+ console.warn(`Failed to create scan branch: ${err.message}`);
4403
+ return { prNumber, sha, branched: false };
4404
+ }
4405
+ try {
4406
+ const newPrBody = `Security scan of closed PR #${prNumber} (\`${pr.head.ref}\`).
4407
+
4408
+ This PR contains no code changes \u2014 it exists so Synkro can post review findings on an active PR.`;
4409
+ const result = ghJson([
4410
+ "api",
4411
+ "-X",
4412
+ "POST",
4413
+ `/repos/${repo}/pulls`,
4414
+ "-f",
4415
+ `title=Synkro Scan: ${pr.title}`,
4416
+ "-f",
4417
+ `body=${newPrBody}`,
4418
+ "-f",
4419
+ `head=${scanBranch}`,
4420
+ "-f",
4421
+ `base=${pr.base.ref}`
4422
+ ]);
4423
+ console.log(`Opened PR #${result.number} for scan findings.
4424
+ `);
4425
+ return { prNumber: result.number, sha: result.head.sha, branched: true };
4426
+ } catch (err) {
4427
+ console.warn(`Failed to open scan PR: ${err.message}`);
4428
+ return { prNumber, sha, branched: false };
4429
+ }
4430
+ }
4377
4431
  function getPrFiles(repo, prNumber) {
4378
4432
  const data = ghJson([
4379
4433
  "api",
@@ -4381,23 +4435,43 @@ function getPrFiles(repo, prNumber) {
4381
4435
  ]);
4382
4436
  return data;
4383
4437
  }
4438
+ function getLastReviewedSha(repo, prNumber) {
4439
+ try {
4440
+ const reviews = ghJson([
4441
+ "api",
4442
+ `/repos/${repo}/pulls/${prNumber}/reviews?per_page=100`
4443
+ ]);
4444
+ const synkro = reviews.filter((r) => r.body?.includes("Synkro Security Review")).sort((a, b) => new Date(b.submitted_at).getTime() - new Date(a.submitted_at).getTime());
4445
+ return synkro.length > 0 ? synkro[0].commit_id : null;
4446
+ } catch {
4447
+ return null;
4448
+ }
4449
+ }
4450
+ function getChangedFilesSince(repo, baseSha, headSha) {
4451
+ try {
4452
+ const data = ghJson([
4453
+ "api",
4454
+ `/repos/${repo}/compare/${baseSha}...${headSha}`
4455
+ ]);
4456
+ return (data.files || []).map((f) => f.filename);
4457
+ } catch {
4458
+ return null;
4459
+ }
4460
+ }
4384
4461
  async function fetchScanContext(gatewayUrl, apiKey, repo, prNumber, sha) {
4462
+ const lastSha = getLastReviewedSha(repo, prNumber);
4463
+ const changedFiles = lastSha && lastSha !== sha ? getChangedFilesSince(repo, lastSha, sha) : void 0;
4385
4464
  try {
4386
- const url = `${gatewayUrl.replace(/\/$/, "")}/api/pr-scans/scan-context?repo=${encodeURIComponent(repo)}&pr_number=${prNumber}&sha=${sha}`;
4387
- const headers = { "x-synkro-api-key": apiKey };
4388
- const ghToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "";
4389
- if (ghToken) headers["x-github-token"] = ghToken;
4390
- console.log(`[scan-context] POST ${url}`);
4465
+ const url = `${gatewayUrl.replace(/\/$/, "")}/api/pr-scans/scan-context`;
4391
4466
  const resp = await fetch(url, {
4392
- headers,
4467
+ method: "POST",
4468
+ headers: { "x-synkro-api-key": apiKey, "Content-Type": "application/json" },
4469
+ body: JSON.stringify({ sha, last_reviewed_sha: lastSha, changed_files: changedFiles }),
4393
4470
  signal: AbortSignal.timeout(15e3)
4394
4471
  });
4395
- const body = await resp.text();
4396
- console.log(`[scan-context] ${resp.status}: ${body.slice(0, 300)}`);
4397
4472
  if (!resp.ok) return { scan_all: true };
4398
- return JSON.parse(body);
4399
- } catch (err) {
4400
- console.warn(`[scan-context] error: ${err.message}`);
4473
+ return await resp.json();
4474
+ } catch {
4401
4475
  return { scan_all: true };
4402
4476
  }
4403
4477
  }
@@ -4746,6 +4820,9 @@ async function scanPrCommand() {
4746
4820
  }
4747
4821
  console.log(`Synkro scan-pr: ${repo}#${prNumber} @ ${sha.slice(0, 7)}
4748
4822
  `);
4823
+ const prTarget = await ensureOpenPr(repo, prNumber, sha);
4824
+ const activePrNumber = prTarget.prNumber;
4825
+ const activeSha = prTarget.sha;
4749
4826
  const orgRules = await fetchOrgRules(gatewayUrl, synkroApiKey);
4750
4827
  const auditRules = orgRules.filter((r) => r.mode === "audit");
4751
4828
  const literalNegativeRules = orgRules.filter((r) => {
@@ -4756,22 +4833,22 @@ async function scanPrCommand() {
4756
4833
  const promptHeader = buildPrPrompt(auditRules);
4757
4834
  let files;
4758
4835
  try {
4759
- files = getPrFiles(repo, prNumber);
4836
+ files = getPrFiles(repo, activePrNumber);
4760
4837
  } catch (err) {
4761
4838
  console.error("Failed to fetch PR files:", err.message);
4762
4839
  process.exit(2);
4763
4840
  }
4764
- const scanCtx = await fetchScanContext(gatewayUrl, synkroApiKey, repo, prNumber, sha);
4841
+ const scanCtx = await fetchScanContext(gatewayUrl, synkroApiKey, repo, activePrNumber, activeSha);
4765
4842
  if (scanCtx.skip) {
4766
- console.log(`Already scanned at ${sha.slice(0, 7)}, skipping.
4843
+ console.log(`Already scanned at ${activeSha.slice(0, 7)}, skipping.
4767
4844
  `);
4768
- postCheckRun(repo, sha, "success", []);
4845
+ postCheckRun(repo, activeSha, "success", []);
4769
4846
  await postEventToBackend({
4770
4847
  gatewayUrl,
4771
4848
  apiKey: synkroApiKey,
4772
4849
  repo,
4773
- prNumber,
4774
- sha,
4850
+ prNumber: activePrNumber,
4851
+ sha: activeSha,
4775
4852
  findings: [],
4776
4853
  filesScanned: 0,
4777
4854
  totalLatencyMs: 0
@@ -4794,13 +4871,13 @@ async function scanPrCommand() {
4794
4871
  console.log(`${files.length} files in PR, ${eligible.length} eligible for scan.
4795
4872
  `);
4796
4873
  if (eligible.length === 0) {
4797
- postCheckRun(repo, sha, "success", []);
4874
+ postCheckRun(repo, activeSha, "success", []);
4798
4875
  await postEventToBackend({
4799
4876
  gatewayUrl,
4800
4877
  apiKey: synkroApiKey,
4801
4878
  repo,
4802
- prNumber,
4803
- sha,
4879
+ prNumber: activePrNumber,
4880
+ sha: activeSha,
4804
4881
  findings: [],
4805
4882
  filesScanned: 0,
4806
4883
  totalLatencyMs: 0
@@ -4827,17 +4904,17 @@ Total: ${allFindings.length} finding(s) across ${eligible.length} file(s) in ${t
4827
4904
  const review = await spawnOpusConsolidator(allFindings, claudeToken);
4828
4905
  console.log(` \u2192 ${review.comments.length} review comment(s), severity: ${review.severity}`);
4829
4906
  if (review.comments.length > 0) {
4830
- postPrReview(repo, prNumber, sha, review);
4907
+ postPrReview(repo, activePrNumber, activeSha, review);
4831
4908
  }
4832
4909
  }
4833
4910
  const conclusion = shouldFail(allFindings, failThreshold) ? "failure" : "success";
4834
- postCheckRun(repo, sha, conclusion, allFindings);
4911
+ postCheckRun(repo, activeSha, conclusion, allFindings);
4835
4912
  await postEventToBackend({
4836
4913
  gatewayUrl,
4837
4914
  apiKey: synkroApiKey,
4838
4915
  repo,
4839
- prNumber,
4840
- sha,
4916
+ prNumber: activePrNumber,
4917
+ sha: activeSha,
4841
4918
  findings: allFindings,
4842
4919
  filesScanned: eligible.length,
4843
4920
  totalLatencyMs