@synkro-sh/cli 1.0.11 → 1.0.12
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 +167 -55
- package/dist/bootstrap.js.map +1 -1
- package/package.json +1 -1
package/dist/bootstrap.js
CHANGED
|
@@ -360,10 +360,22 @@ esac
|
|
|
360
360
|
if [ "\${SYNKRO_HEADLESS:-0}" = "1" ]; then IS_HEADLESS=1; fi
|
|
361
361
|
|
|
362
362
|
USER_INTENT=""
|
|
363
|
+
RECENT_USER_MESSAGES="[]"
|
|
363
364
|
RECENT_ACTIONS="[]"
|
|
364
365
|
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
|
365
|
-
# Last user
|
|
366
|
-
|
|
366
|
+
# Last 5 user-role messages, oldest first. Lets the grader see consent
|
|
367
|
+
# that carried over from a recent prior turn \u2014 saying "i consent" two
|
|
368
|
+
# turns ago should not require re-prompting on this turn's command.
|
|
369
|
+
RECENT_USER_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
|
|
370
|
+
[.[]
|
|
371
|
+
| select(.type == "user")
|
|
372
|
+
| (.message.content
|
|
373
|
+
| if type == "string" then .
|
|
374
|
+
else (map(.text? // "") | join(" "))
|
|
375
|
+
end)
|
|
376
|
+
| select(. != null and . != "")
|
|
377
|
+
] | .[-5:]' 2>/dev/null || echo "[]")
|
|
378
|
+
USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
|
|
367
379
|
# Recent agent actions (last 5 tool_use blocks)
|
|
368
380
|
RECENT_ACTIONS=$(tail -200 "$TRANSCRIPT_PATH" | jq -c -s '
|
|
369
381
|
[.[]
|
|
@@ -380,6 +392,7 @@ fi
|
|
|
380
392
|
BODY=$(jq -n \\
|
|
381
393
|
--argjson tool_input "$TOOL_INPUT" \\
|
|
382
394
|
--arg user_intent "$USER_INTENT" \\
|
|
395
|
+
--argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
|
|
383
396
|
--argjson recent_actions "$RECENT_ACTIONS" \\
|
|
384
397
|
--arg session_id "$SESSION_ID" \\
|
|
385
398
|
--arg tool_use_id "$TOOL_USE_ID" \\
|
|
@@ -388,6 +401,7 @@ BODY=$(jq -n \\
|
|
|
388
401
|
kind: "bash_judge",
|
|
389
402
|
tool_input: $tool_input,
|
|
390
403
|
user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
|
|
404
|
+
recent_user_messages: $recent_user_messages,
|
|
391
405
|
recent_actions: $recent_actions,
|
|
392
406
|
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
393
407
|
tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
|
|
@@ -2021,7 +2035,7 @@ function writeConfigEnv(opts) {
|
|
|
2021
2035
|
`SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
|
|
2022
2036
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
2023
2037
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
2024
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.0.
|
|
2038
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.0.12")}`
|
|
2025
2039
|
];
|
|
2026
2040
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
2027
2041
|
if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
|
|
@@ -2422,31 +2436,17 @@ jobs:
|
|
|
2422
2436
|
with:
|
|
2423
2437
|
fetch-depth: 0
|
|
2424
2438
|
|
|
2425
|
-
- name: Cache
|
|
2426
|
-
id: cache-
|
|
2427
|
-
uses: actions/cache@v4
|
|
2428
|
-
with:
|
|
2429
|
-
path: ~/.local/bin/synkro
|
|
2430
|
-
key: synkro-\${{ runner.os }}-latest
|
|
2431
|
-
|
|
2432
|
-
- name: Install Synkro CLI
|
|
2433
|
-
if: steps.cache-synkro.outputs.cache-hit != 'true'
|
|
2434
|
-
run: |
|
|
2435
|
-
mkdir -p ~/.local/bin
|
|
2436
|
-
curl -fsSL https://get.synkro.sh | SYNKRO_INSTALL_DIR=~/.local/bin bash
|
|
2437
|
-
echo "~/.local/bin" >> $GITHUB_PATH
|
|
2438
|
-
|
|
2439
|
-
- name: Cache Claude Code CLI
|
|
2440
|
-
id: cache-claude
|
|
2439
|
+
- name: Cache npm globals
|
|
2440
|
+
id: cache-npm-global
|
|
2441
2441
|
uses: actions/cache@v4
|
|
2442
2442
|
with:
|
|
2443
2443
|
path: ~/.npm-global
|
|
2444
|
-
key:
|
|
2444
|
+
key: synkro-cli-\${{ runner.os }}-v1
|
|
2445
2445
|
|
|
2446
|
-
- name: Install Claude Code CLI
|
|
2446
|
+
- name: Install Synkro CLI + Claude Code CLI
|
|
2447
2447
|
run: |
|
|
2448
2448
|
npm config set prefix ~/.npm-global
|
|
2449
|
-
npm install -g @anthropic-ai/claude-code
|
|
2449
|
+
npm install -g @synkro-sh/cli @anthropic-ai/claude-code
|
|
2450
2450
|
echo "~/.npm-global/bin" >> $GITHUB_PATH
|
|
2451
2451
|
|
|
2452
2452
|
- name: Run Synkro PR scan
|
|
@@ -2628,8 +2628,33 @@ async function setupGithubCommand() {
|
|
|
2628
2628
|
process.exit(1);
|
|
2629
2629
|
}
|
|
2630
2630
|
const config = readConfig();
|
|
2631
|
-
|
|
2632
|
-
|
|
2631
|
+
const gatewayUrl = (config.SYNKRO_GATEWAY_URL || process.env.SYNKRO_GATEWAY_URL || "https://api.synkro.sh").replace(/\/$/, "");
|
|
2632
|
+
const jwt2 = getAccessToken();
|
|
2633
|
+
if (!jwt2) {
|
|
2634
|
+
console.error("Could not load access token from ~/.synkro/credentials.json. Run `synkro login`.");
|
|
2635
|
+
process.exit(1);
|
|
2636
|
+
}
|
|
2637
|
+
console.log("Requesting CI API key from Synkro...");
|
|
2638
|
+
let synkroCiApiKey;
|
|
2639
|
+
try {
|
|
2640
|
+
const resp = await fetch(`${gatewayUrl}/api/v1/cli/ci-api-key`, {
|
|
2641
|
+
method: "POST",
|
|
2642
|
+
headers: {
|
|
2643
|
+
"Authorization": `Bearer ${jwt2}`,
|
|
2644
|
+
"Content-Type": "application/json"
|
|
2645
|
+
},
|
|
2646
|
+
body: "{}"
|
|
2647
|
+
});
|
|
2648
|
+
if (!resp.ok) {
|
|
2649
|
+
const errText = await resp.text().catch(() => "");
|
|
2650
|
+
console.error(`Failed to mint CI API key (${resp.status}): ${errText.slice(0, 200)}`);
|
|
2651
|
+
process.exit(1);
|
|
2652
|
+
}
|
|
2653
|
+
const minted = await resp.json();
|
|
2654
|
+
synkroCiApiKey = minted.api_key;
|
|
2655
|
+
console.log(` \u2713 Issued CI key (${synkroCiApiKey.slice(0, 18)}\u2026), expires ${minted.expires_at.slice(0, 10)}`);
|
|
2656
|
+
} catch (err) {
|
|
2657
|
+
console.error(`Failed to mint CI API key: ${err.message}`);
|
|
2633
2658
|
process.exit(1);
|
|
2634
2659
|
}
|
|
2635
2660
|
const rl = createInterface({ input, output });
|
|
@@ -2697,7 +2722,7 @@ Will push secrets to ${selected.length} repo(s):`);
|
|
|
2697
2722
|
r.repo,
|
|
2698
2723
|
{
|
|
2699
2724
|
claudeCodeOauthToken: claudeToken,
|
|
2700
|
-
synkroApiKey:
|
|
2725
|
+
synkroApiKey: synkroCiApiKey
|
|
2701
2726
|
}
|
|
2702
2727
|
);
|
|
2703
2728
|
console.log("\u2713");
|
|
@@ -2740,6 +2765,107 @@ __export(scanPr_exports, {
|
|
|
2740
2765
|
scanPrCommand: () => scanPrCommand
|
|
2741
2766
|
});
|
|
2742
2767
|
import { execSync as execSync2, spawn } from "child_process";
|
|
2768
|
+
function parseMatchSpec(condition) {
|
|
2769
|
+
if (!condition.startsWith("match_spec:")) return null;
|
|
2770
|
+
try {
|
|
2771
|
+
const parsed = JSON.parse(condition.slice("match_spec:".length));
|
|
2772
|
+
if (typeof parsed?.selector !== "string" || typeof parsed?.requires !== "string" || parsed.position !== "start" && parsed.position !== "anywhere") return null;
|
|
2773
|
+
return {
|
|
2774
|
+
selector: parsed.selector,
|
|
2775
|
+
requires: parsed.requires,
|
|
2776
|
+
position: parsed.position,
|
|
2777
|
+
negate: parsed.negate === true
|
|
2778
|
+
};
|
|
2779
|
+
} catch {
|
|
2780
|
+
return null;
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
function selectorMatches(selector, filePath) {
|
|
2784
|
+
const m = selector.match(/^\*\*\/\*\.([a-z0-9]+)$/i);
|
|
2785
|
+
if (!m) return false;
|
|
2786
|
+
return filePath.toLowerCase().endsWith("." + m[1].toLowerCase());
|
|
2787
|
+
}
|
|
2788
|
+
async function fetchOrgRules(gatewayUrl, apiKey) {
|
|
2789
|
+
try {
|
|
2790
|
+
const resp = await fetch(`${gatewayUrl.replace(/\/$/, "")}/api/v1/cli/pr-rules`, {
|
|
2791
|
+
headers: { "x-synkro-api-key": apiKey }
|
|
2792
|
+
});
|
|
2793
|
+
if (!resp.ok) {
|
|
2794
|
+
console.warn(`[scan-pr] failed to fetch org rules: HTTP ${resp.status}`);
|
|
2795
|
+
return [];
|
|
2796
|
+
}
|
|
2797
|
+
const data = await resp.json();
|
|
2798
|
+
return Array.isArray(data?.rules) ? data.rules : [];
|
|
2799
|
+
} catch (err) {
|
|
2800
|
+
console.warn(`[scan-pr] could not fetch org rules: ${err.message}`);
|
|
2801
|
+
return [];
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
function applyLiteralMatchNegative(rules, file) {
|
|
2805
|
+
if (!file.patch) return [];
|
|
2806
|
+
const findings = [];
|
|
2807
|
+
const lines = file.patch.split("\n");
|
|
2808
|
+
let currentNewLine = 0;
|
|
2809
|
+
for (const line of lines) {
|
|
2810
|
+
if (line.startsWith("@@")) {
|
|
2811
|
+
const m = line.match(/\+(\d+)(?:,\d+)?/);
|
|
2812
|
+
if (m) currentNewLine = parseInt(m[1], 10);
|
|
2813
|
+
continue;
|
|
2814
|
+
}
|
|
2815
|
+
if (line.startsWith("+++") || line.startsWith("---")) continue;
|
|
2816
|
+
if (!line.startsWith("+")) {
|
|
2817
|
+
if (!line.startsWith("-")) currentNewLine++;
|
|
2818
|
+
continue;
|
|
2819
|
+
}
|
|
2820
|
+
const addedContent = line.slice(1);
|
|
2821
|
+
for (const r of rules) {
|
|
2822
|
+
if (r.mode !== "literal_match") continue;
|
|
2823
|
+
const spec = parseMatchSpec(r.condition);
|
|
2824
|
+
if (!spec || !spec.negate) continue;
|
|
2825
|
+
if (!selectorMatches(spec.selector, file.filename)) continue;
|
|
2826
|
+
if (!addedContent.includes(spec.requires)) continue;
|
|
2827
|
+
findings.push({
|
|
2828
|
+
file: file.filename,
|
|
2829
|
+
line: currentNewLine,
|
|
2830
|
+
severity: r.severity ?? "high",
|
|
2831
|
+
category: r.category || "literal_match",
|
|
2832
|
+
description: r.text,
|
|
2833
|
+
fix: `Remove \`${spec.requires}\` from ${file.filename} (rule ${r.rule_id}).`
|
|
2834
|
+
});
|
|
2835
|
+
}
|
|
2836
|
+
currentNewLine++;
|
|
2837
|
+
}
|
|
2838
|
+
return findings;
|
|
2839
|
+
}
|
|
2840
|
+
function buildPrPrompt(orgAuditRules) {
|
|
2841
|
+
const orgRulesBlock = orgAuditRules.length === 0 ? "" : `
|
|
2842
|
+
ORG-SPECIFIC RULES (these are the customer's policies \u2014 flag any violation found in the diff):
|
|
2843
|
+
` + orgAuditRules.map((r, i) => ` ${i + 1}. [${r.severity}/${r.category}] ${r.text}`).join("\n") + "\n";
|
|
2844
|
+
return `You are a security code reviewer analyzing a pull request diff for one file. Identify security issues + org-policy violations introduced by this diff.
|
|
2845
|
+
|
|
2846
|
+
Output ONLY a JSON object (no prose, no markdown fences):
|
|
2847
|
+
{
|
|
2848
|
+
"findings": [
|
|
2849
|
+
{
|
|
2850
|
+
"line": <int \u2014 line number in the NEW file, prefixed with L in the diff>,
|
|
2851
|
+
"severity": "low" | "medium" | "high" | "critical",
|
|
2852
|
+
"category": "<snake_case>",
|
|
2853
|
+
"description": "<1 sentence>",
|
|
2854
|
+
"fix": "<concrete suggestion>"
|
|
2855
|
+
}
|
|
2856
|
+
],
|
|
2857
|
+
"summary": "<one-line: 'X findings' or 'clean'>"
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
Baseline security categories: hardcoded_secret, sql_injection, insecure_crypto, eval_exec, unsafe_deserialization, missing_validation, exposed_internal, missing_auth_check, cors_misconfig, path_traversal, command_injection, weak_random, broken_jwt.
|
|
2861
|
+
${orgRulesBlock}
|
|
2862
|
+
Rules:
|
|
2863
|
+
- Only flag NEW issues (lines starting with +).
|
|
2864
|
+
- Use the L<num> line numbers I prefixed.
|
|
2865
|
+
- Be specific. If clean, return {"findings": [], "summary": "clean"}.
|
|
2866
|
+
|
|
2867
|
+
`;
|
|
2868
|
+
}
|
|
2743
2869
|
function shouldSkipFile(filename) {
|
|
2744
2870
|
return SKIP_FILE_PATTERNS.some((p) => p.test(filename));
|
|
2745
2871
|
}
|
|
@@ -2789,13 +2915,13 @@ function getFileDiffWithLines(file) {
|
|
|
2789
2915
|
}
|
|
2790
2916
|
return { hunks: annotated.join("\n"), newFileLineMap: lineMap };
|
|
2791
2917
|
}
|
|
2792
|
-
function spawnClaudeJudge(file, claudeToken) {
|
|
2918
|
+
function spawnClaudeJudge(file, claudeToken, promptHeader) {
|
|
2793
2919
|
const { hunks } = getFileDiffWithLines(file);
|
|
2794
2920
|
const userMessage = `File: ${file.filename}
|
|
2795
2921
|
|
|
2796
2922
|
Diff:
|
|
2797
2923
|
${hunks}`;
|
|
2798
|
-
const fullPrompt =
|
|
2924
|
+
const fullPrompt = promptHeader + userMessage;
|
|
2799
2925
|
return new Promise((resolve2) => {
|
|
2800
2926
|
const t0 = Date.now();
|
|
2801
2927
|
const proc = spawn(
|
|
@@ -2937,6 +3063,14 @@ async function scanPrCommand() {
|
|
|
2937
3063
|
}
|
|
2938
3064
|
console.log(`Synkro scan-pr: ${repo}#${prNumber} @ ${sha.slice(0, 7)}
|
|
2939
3065
|
`);
|
|
3066
|
+
const orgRules = await fetchOrgRules(gatewayUrl, synkroApiKey);
|
|
3067
|
+
const auditRules = orgRules.filter((r) => r.mode === "audit");
|
|
3068
|
+
const literalNegativeRules = orgRules.filter((r) => {
|
|
3069
|
+
const spec = parseMatchSpec(r.condition);
|
|
3070
|
+
return r.mode === "literal_match" && spec?.negate === true;
|
|
3071
|
+
});
|
|
3072
|
+
console.log(`Loaded ${orgRules.length} org rule(s): ${auditRules.length} audit, ${literalNegativeRules.length} literal_match (negative).`);
|
|
3073
|
+
const promptHeader = buildPrPrompt(auditRules);
|
|
2940
3074
|
let files;
|
|
2941
3075
|
try {
|
|
2942
3076
|
files = getPrFiles(repo, prNumber);
|
|
@@ -2971,9 +3105,11 @@ async function scanPrCommand() {
|
|
|
2971
3105
|
const t0 = Date.now();
|
|
2972
3106
|
const results = await processInBatches(eligible, MAX_PARALLEL_FILES, async (file) => {
|
|
2973
3107
|
process.stdout.write(`Scanning ${file.filename}...`);
|
|
2974
|
-
const
|
|
2975
|
-
|
|
2976
|
-
|
|
3108
|
+
const literalFindings = applyLiteralMatchNegative(literalNegativeRules, file);
|
|
3109
|
+
const llmResult = await spawnClaudeJudge(file, claudeToken, promptHeader);
|
|
3110
|
+
const merged = [...literalFindings, ...llmResult.findings];
|
|
3111
|
+
console.log(` ${merged.length} finding(s) (${literalFindings.length} literal, ${llmResult.findings.length} llm; ${llmResult.latencyMs}ms)`);
|
|
3112
|
+
return { findings: merged, latencyMs: llmResult.latencyMs };
|
|
2977
3113
|
});
|
|
2978
3114
|
const totalLatencyMs = Date.now() - t0;
|
|
2979
3115
|
const allFindings = results.flatMap((r) => r.findings);
|
|
@@ -3001,7 +3137,7 @@ Total: ${allFindings.length} finding(s) across ${eligible.length} file(s) in ${t
|
|
|
3001
3137
|
process.exit(1);
|
|
3002
3138
|
}
|
|
3003
3139
|
}
|
|
3004
|
-
var SKIP_FILE_PATTERNS, MAX_DIFF_LINES_PER_FILE, MAX_PARALLEL_FILES
|
|
3140
|
+
var SKIP_FILE_PATTERNS, MAX_DIFF_LINES_PER_FILE, MAX_PARALLEL_FILES;
|
|
3005
3141
|
var init_scanPr = __esm({
|
|
3006
3142
|
"cli/commands/scanPr.ts"() {
|
|
3007
3143
|
"use strict";
|
|
@@ -3022,30 +3158,6 @@ var init_scanPr = __esm({
|
|
|
3022
3158
|
];
|
|
3023
3159
|
MAX_DIFF_LINES_PER_FILE = 1e3;
|
|
3024
3160
|
MAX_PARALLEL_FILES = 5;
|
|
3025
|
-
PR_PROMPT_HEADER = `You are a security code reviewer analyzing a pull request diff for one file. Identify security issues introduced by this diff.
|
|
3026
|
-
|
|
3027
|
-
Output ONLY a JSON object (no prose, no markdown fences):
|
|
3028
|
-
{
|
|
3029
|
-
"findings": [
|
|
3030
|
-
{
|
|
3031
|
-
"line": <int \u2014 line number in the NEW file, prefixed with L in the diff>,
|
|
3032
|
-
"severity": "low" | "medium" | "high" | "critical",
|
|
3033
|
-
"category": "<snake_case>",
|
|
3034
|
-
"description": "<1 sentence>",
|
|
3035
|
-
"fix": "<concrete suggestion>"
|
|
3036
|
-
}
|
|
3037
|
-
],
|
|
3038
|
-
"summary": "<one-line: 'X findings' or 'clean'>"
|
|
3039
|
-
}
|
|
3040
|
-
|
|
3041
|
-
Categories: hardcoded_secret, sql_injection, insecure_crypto, eval_exec, unsafe_deserialization, missing_validation, exposed_internal, missing_auth_check, cors_misconfig, path_traversal, command_injection, weak_random, broken_jwt.
|
|
3042
|
-
|
|
3043
|
-
Rules:
|
|
3044
|
-
- Only flag NEW issues (lines starting with +).
|
|
3045
|
-
- Use the L<num> line numbers I prefixed.
|
|
3046
|
-
- Be specific. If clean, return {"findings": [], "summary": "clean"}.
|
|
3047
|
-
|
|
3048
|
-
`;
|
|
3049
3161
|
}
|
|
3050
3162
|
});
|
|
3051
3163
|
|