@rafter-security/cli 0.5.5 → 0.6.1
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/README.md +15 -3
- package/dist/commands/agent/audit-skill.js +1 -1
- package/dist/commands/agent/audit.js +106 -6
- package/dist/commands/agent/baseline.js +10 -0
- package/dist/commands/agent/exec.js +1 -1
- package/dist/commands/agent/init.js +366 -26
- package/dist/commands/agent/scan.js +160 -16
- package/dist/commands/agent/status.js +65 -4
- package/dist/commands/agent/verify.js +18 -4
- package/dist/commands/backend/run.js +76 -62
- package/dist/commands/ci/init.js +10 -3
- package/dist/commands/completion.js +21 -9
- package/dist/commands/hook/posttool.js +21 -7
- package/dist/commands/hook/pretool.js +50 -13
- package/dist/commands/issues/dedup.js +39 -0
- package/dist/commands/issues/from-scan.js +143 -0
- package/dist/commands/issues/from-text.js +185 -0
- package/dist/commands/issues/github-client.js +85 -0
- package/dist/commands/issues/index.js +25 -0
- package/dist/commands/issues/issue-builder.js +101 -0
- package/dist/commands/mcp/server.js +4 -1
- package/dist/commands/policy/export.js +7 -2
- package/dist/commands/scan/index.js +45 -0
- package/dist/core/audit-logger.js +106 -7
- package/dist/core/config-defaults.js +24 -0
- package/dist/core/config-manager.js +116 -3
- package/dist/core/custom-patterns.js +20 -17
- package/dist/core/pattern-engine.js +26 -1
- package/dist/core/policy-loader.js +25 -2
- package/dist/index.js +11 -2
- package/dist/scanners/gitleaks.js +8 -7
- package/dist/scanners/regex-scanner.js +16 -1
- package/dist/scanners/secret-patterns.js +6 -6
- package/dist/utils/api.js +18 -0
- package/dist/utils/binary-manager.js +74 -7
- package/dist/utils/skill-manager.js +5 -3
- package/package.json +5 -3
- package/resources/pre-commit-hook.sh +2 -2
- package/resources/pre-push-hook.sh +2 -2
- package/resources/rafter-security-skill.md +7 -11
|
@@ -62,7 +62,7 @@ _rafter_completions() {
|
|
|
62
62
|
;;
|
|
63
63
|
init)
|
|
64
64
|
if [[ "\${COMP_WORDS[1]}" == "agent" ]]; then
|
|
65
|
-
COMPREPLY=( $(compgen -W "--risk-level --
|
|
65
|
+
COMPREPLY=( $(compgen -W "--risk-level --with-openclaw --with-claude-code --with-codex --with-gemini --with-aider --with-cursor --with-windsurf --with-continue --with-gitleaks --all --help" -- "\${cur}") )
|
|
66
66
|
elif [[ "\${COMP_WORDS[1]}" == "ci" ]]; then
|
|
67
67
|
COMPREPLY=( $(compgen -W "--platform --output --with-backend --help" -- "\${cur}") )
|
|
68
68
|
fi
|
|
@@ -147,10 +147,16 @@ _rafter() {
|
|
|
147
147
|
init)
|
|
148
148
|
_arguments \\
|
|
149
149
|
'--risk-level[Risk level]:level:(minimal moderate aggressive)' \\
|
|
150
|
-
'--
|
|
151
|
-
'--
|
|
152
|
-
'--
|
|
153
|
-
'--
|
|
150
|
+
'--with-openclaw[Install OpenClaw integration]' \\
|
|
151
|
+
'--with-claude-code[Install Claude Code integration]' \\
|
|
152
|
+
'--with-codex[Install Codex CLI integration]' \\
|
|
153
|
+
'--with-gemini[Install Gemini CLI integration]' \\
|
|
154
|
+
'--with-aider[Install Aider integration]' \\
|
|
155
|
+
'--with-cursor[Install Cursor integration]' \\
|
|
156
|
+
'--with-windsurf[Install Windsurf integration]' \\
|
|
157
|
+
'--with-continue[Install Continue.dev integration]' \\
|
|
158
|
+
'--with-gitleaks[Download Gitleaks binary]' \\
|
|
159
|
+
'--all[Install all detected integrations]'
|
|
154
160
|
;;
|
|
155
161
|
audit)
|
|
156
162
|
_arguments \\
|
|
@@ -297,10 +303,16 @@ complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcom
|
|
|
297
303
|
|
|
298
304
|
# agent init options
|
|
299
305
|
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l risk-level -d 'Risk level' -ra 'minimal moderate aggressive'
|
|
300
|
-
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l
|
|
301
|
-
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l
|
|
302
|
-
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l
|
|
303
|
-
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l
|
|
306
|
+
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-openclaw -d 'Install OpenClaw'
|
|
307
|
+
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-claude-code -d 'Install Claude Code'
|
|
308
|
+
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-codex -d 'Install Codex CLI'
|
|
309
|
+
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-gemini -d 'Install Gemini CLI'
|
|
310
|
+
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-aider -d 'Install Aider'
|
|
311
|
+
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-cursor -d 'Install Cursor'
|
|
312
|
+
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-windsurf -d 'Install Windsurf'
|
|
313
|
+
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-continue -d 'Install Continue.dev'
|
|
314
|
+
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-gitleaks -d 'Install Gitleaks'
|
|
315
|
+
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l all -d 'Install all detected'
|
|
304
316
|
|
|
305
317
|
# agent audit options
|
|
306
318
|
complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from audit' -l last -d 'Show last N entries' -r
|
|
@@ -5,17 +5,28 @@ export function createHookPosttoolCommand() {
|
|
|
5
5
|
return new Command("posttool")
|
|
6
6
|
.description("PostToolUse hook handler (reads stdin, redacts secrets in output, writes JSON to stdout)")
|
|
7
7
|
.action(async () => {
|
|
8
|
-
const input = await readStdin();
|
|
9
|
-
let payload;
|
|
10
8
|
try {
|
|
11
|
-
|
|
9
|
+
const input = await readStdin();
|
|
10
|
+
let payload;
|
|
11
|
+
try {
|
|
12
|
+
payload = JSON.parse(input);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
writeOutput({ action: "continue" });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
// Validate payload is an object with expected shape
|
|
19
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
20
|
+
writeOutput({ action: "continue" });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const output = evaluateToolResponse(payload);
|
|
24
|
+
writeOutput(output);
|
|
12
25
|
}
|
|
13
26
|
catch {
|
|
27
|
+
// Any unexpected error → fail open
|
|
14
28
|
writeOutput({ action: "continue" });
|
|
15
|
-
return;
|
|
16
29
|
}
|
|
17
|
-
const output = evaluateToolResponse(payload);
|
|
18
|
-
writeOutput(output);
|
|
19
30
|
});
|
|
20
31
|
}
|
|
21
32
|
function evaluateToolResponse(payload) {
|
|
@@ -59,12 +70,15 @@ function countMatches(scanner, tool_response) {
|
|
|
59
70
|
}
|
|
60
71
|
return count;
|
|
61
72
|
}
|
|
73
|
+
const STDIN_TIMEOUT_MS = 5000;
|
|
62
74
|
function readStdin() {
|
|
63
75
|
return new Promise((resolve) => {
|
|
64
76
|
let data = "";
|
|
77
|
+
const timeout = setTimeout(() => { resolve(data); }, STDIN_TIMEOUT_MS);
|
|
65
78
|
process.stdin.setEncoding("utf-8");
|
|
66
79
|
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
67
|
-
process.stdin.on("end", () => { resolve(data); });
|
|
80
|
+
process.stdin.on("end", () => { clearTimeout(timeout); resolve(data); });
|
|
81
|
+
process.stdin.on("error", () => { clearTimeout(timeout); resolve(data); });
|
|
68
82
|
process.stdin.resume();
|
|
69
83
|
});
|
|
70
84
|
}
|
|
@@ -3,31 +3,65 @@ import { CommandInterceptor } from "../../core/command-interceptor.js";
|
|
|
3
3
|
import { RegexScanner } from "../../scanners/regex-scanner.js";
|
|
4
4
|
import { AuditLogger } from "../../core/audit-logger.js";
|
|
5
5
|
import { execSync } from "child_process";
|
|
6
|
+
const RISK_LABELS = {
|
|
7
|
+
critical: "CRITICAL", high: "HIGH", medium: "MEDIUM", low: "LOW",
|
|
8
|
+
};
|
|
9
|
+
const RISK_DESCRIPTIONS = {
|
|
10
|
+
critical: "irreversible system damage",
|
|
11
|
+
high: "significant system changes",
|
|
12
|
+
medium: "moderate risk operation",
|
|
13
|
+
low: "minimal risk",
|
|
14
|
+
};
|
|
15
|
+
function formatBlockedMessage(command, evaluation) {
|
|
16
|
+
const cmdDisplay = command.length > 60 ? command.slice(0, 60) + "..." : command;
|
|
17
|
+
const rule = evaluation.matchedPattern ?? "policy violation";
|
|
18
|
+
const label = RISK_LABELS[evaluation.riskLevel] ?? evaluation.riskLevel.toUpperCase();
|
|
19
|
+
const desc = RISK_DESCRIPTIONS[evaluation.riskLevel] ?? "";
|
|
20
|
+
return `\u2717 Rafter blocked: ${cmdDisplay}\n Rule: ${rule}\n Risk: ${label}\u2014${desc}`;
|
|
21
|
+
}
|
|
22
|
+
function formatApprovalMessage(command, evaluation) {
|
|
23
|
+
const cmdDisplay = command.length > 60 ? command.slice(0, 60) + "..." : command;
|
|
24
|
+
const rule = evaluation.matchedPattern ?? "policy match";
|
|
25
|
+
const label = RISK_LABELS[evaluation.riskLevel] ?? evaluation.riskLevel.toUpperCase();
|
|
26
|
+
const desc = RISK_DESCRIPTIONS[evaluation.riskLevel] ?? "";
|
|
27
|
+
return `\u26a0 Rafter: approval required\n Command: ${cmdDisplay}\n Rule: ${rule}\n Risk: ${label}\u2014${desc}\n\nTo approve: rafter agent exec --approve "${command}"\nTo configure: rafter agent config set agent.riskLevel minimal`;
|
|
28
|
+
}
|
|
6
29
|
export function createHookPretoolCommand() {
|
|
7
30
|
return new Command("pretool")
|
|
8
31
|
.description("PreToolUse hook handler (reads stdin, writes JSON decision to stdout)")
|
|
9
32
|
.action(async () => {
|
|
10
|
-
const input = await readStdin();
|
|
11
|
-
let payload;
|
|
12
33
|
try {
|
|
13
|
-
|
|
34
|
+
const input = await readStdin();
|
|
35
|
+
let payload;
|
|
36
|
+
try {
|
|
37
|
+
payload = JSON.parse(input);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Can't parse → fail open
|
|
41
|
+
writeDecision({ decision: "allow" });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// Validate payload is an object with expected shape
|
|
45
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
46
|
+
writeDecision({ decision: "allow" });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const decision = evaluateToolCall(payload);
|
|
50
|
+
writeDecision(decision);
|
|
14
51
|
}
|
|
15
52
|
catch {
|
|
16
|
-
//
|
|
53
|
+
// Any unexpected error → fail open
|
|
17
54
|
writeDecision({ decision: "allow" });
|
|
18
|
-
return;
|
|
19
55
|
}
|
|
20
|
-
const decision = evaluateToolCall(payload);
|
|
21
|
-
writeDecision(decision);
|
|
22
56
|
});
|
|
23
57
|
}
|
|
24
58
|
function evaluateToolCall(payload) {
|
|
25
59
|
const { tool_name, tool_input } = payload;
|
|
26
60
|
if (tool_name === "Bash") {
|
|
27
|
-
return evaluateBash(tool_input
|
|
61
|
+
return evaluateBash(tool_input?.command || "");
|
|
28
62
|
}
|
|
29
63
|
if (tool_name === "Write" || tool_name === "Edit") {
|
|
30
|
-
return evaluateWrite(tool_input);
|
|
64
|
+
return evaluateWrite(tool_input || {});
|
|
31
65
|
}
|
|
32
66
|
return { decision: "allow" };
|
|
33
67
|
}
|
|
@@ -40,7 +74,7 @@ function evaluateBash(command) {
|
|
|
40
74
|
audit.logCommandIntercepted(command, false, "blocked", evaluation.reason);
|
|
41
75
|
return {
|
|
42
76
|
decision: "deny",
|
|
43
|
-
reason:
|
|
77
|
+
reason: formatBlockedMessage(command, evaluation),
|
|
44
78
|
};
|
|
45
79
|
}
|
|
46
80
|
// Requires approval — deny (agent can't provide interactive approval)
|
|
@@ -48,7 +82,7 @@ function evaluateBash(command) {
|
|
|
48
82
|
audit.logCommandIntercepted(command, false, "blocked", evaluation.reason);
|
|
49
83
|
return {
|
|
50
84
|
decision: "deny",
|
|
51
|
-
reason:
|
|
85
|
+
reason: formatApprovalMessage(command, evaluation),
|
|
52
86
|
};
|
|
53
87
|
}
|
|
54
88
|
// Git commit/push — scan staged files for secrets
|
|
@@ -59,7 +93,7 @@ function evaluateBash(command) {
|
|
|
59
93
|
audit.logSecretDetected("staged files", `${scanResult.count} secret(s)`, "blocked");
|
|
60
94
|
return {
|
|
61
95
|
decision: "deny",
|
|
62
|
-
reason: `${scanResult.count} secret(s) detected in ${scanResult.files} staged file(s). Run 'rafter
|
|
96
|
+
reason: `${scanResult.count} secret(s) detected in ${scanResult.files} staged file(s). Run 'rafter scan local --staged' for details.`,
|
|
63
97
|
};
|
|
64
98
|
}
|
|
65
99
|
}
|
|
@@ -108,12 +142,15 @@ function scanStagedFiles() {
|
|
|
108
142
|
return { secretsFound: false, count: 0, files: 0 };
|
|
109
143
|
}
|
|
110
144
|
}
|
|
145
|
+
const STDIN_TIMEOUT_MS = 5000;
|
|
111
146
|
function readStdin() {
|
|
112
147
|
return new Promise((resolve) => {
|
|
113
148
|
let data = "";
|
|
149
|
+
const timeout = setTimeout(() => { resolve(data); }, STDIN_TIMEOUT_MS);
|
|
114
150
|
process.stdin.setEncoding("utf-8");
|
|
115
151
|
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
116
|
-
process.stdin.on("end", () => { resolve(data); });
|
|
152
|
+
process.stdin.on("end", () => { clearTimeout(timeout); resolve(data); });
|
|
153
|
+
process.stdin.on("error", () => { clearTimeout(timeout); resolve(data); });
|
|
117
154
|
process.stdin.resume();
|
|
118
155
|
});
|
|
119
156
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deduplication logic for GitHub issues created from scan findings.
|
|
3
|
+
*
|
|
4
|
+
* Uses a fingerprint hash (file + pattern/ruleId) embedded in the issue body
|
|
5
|
+
* to avoid creating duplicate issues for the same finding.
|
|
6
|
+
*/
|
|
7
|
+
import crypto from "crypto";
|
|
8
|
+
const FINGERPRINT_PREFIX = "<!-- rafter-fingerprint:";
|
|
9
|
+
const FINGERPRINT_SUFFIX = " -->";
|
|
10
|
+
export function fingerprint(file, ruleId) {
|
|
11
|
+
const hash = crypto
|
|
12
|
+
.createHash("sha256")
|
|
13
|
+
.update(`${file}:${ruleId}`)
|
|
14
|
+
.digest("hex")
|
|
15
|
+
.slice(0, 12);
|
|
16
|
+
return hash;
|
|
17
|
+
}
|
|
18
|
+
export function embedFingerprint(body, fp) {
|
|
19
|
+
return `${body}\n\n${FINGERPRINT_PREFIX}${fp}${FINGERPRINT_SUFFIX}`;
|
|
20
|
+
}
|
|
21
|
+
export function extractFingerprint(body) {
|
|
22
|
+
const idx = body.indexOf(FINGERPRINT_PREFIX);
|
|
23
|
+
if (idx === -1)
|
|
24
|
+
return null;
|
|
25
|
+
const start = idx + FINGERPRINT_PREFIX.length;
|
|
26
|
+
const end = body.indexOf(FINGERPRINT_SUFFIX, start);
|
|
27
|
+
if (end === -1)
|
|
28
|
+
return null;
|
|
29
|
+
return body.slice(start, end);
|
|
30
|
+
}
|
|
31
|
+
export function findDuplicates(existingIssues, newFingerprints) {
|
|
32
|
+
const existingFps = new Set();
|
|
33
|
+
for (const issue of existingIssues) {
|
|
34
|
+
const fp = extractFingerprint(issue.body);
|
|
35
|
+
if (fp)
|
|
36
|
+
existingFps.add(fp);
|
|
37
|
+
}
|
|
38
|
+
return new Set(newFingerprints.filter((fp) => existingFps.has(fp)));
|
|
39
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rafter issues create from-scan — create GitHub issues from scan results.
|
|
3
|
+
*
|
|
4
|
+
* Supports both:
|
|
5
|
+
* - Backend scans: --scan-id <id> (fetches from Rafter API)
|
|
6
|
+
* - Local scans: --from-local <path> (reads JSON file from `rafter scan local --format json`)
|
|
7
|
+
*/
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import axios from "axios";
|
|
11
|
+
import { API, resolveKey, EXIT_GENERAL_ERROR } from "../../utils/api.js";
|
|
12
|
+
import { detectRepo } from "../../utils/git.js";
|
|
13
|
+
import { fmt } from "../../utils/formatter.js";
|
|
14
|
+
import { createIssue, listOpenIssues } from "./github-client.js";
|
|
15
|
+
import { findDuplicates } from "./dedup.js";
|
|
16
|
+
import { buildFromBackendVulnerability, buildFromLocalMatch, } from "./issue-builder.js";
|
|
17
|
+
export function createFromScanCommand() {
|
|
18
|
+
return new Command("from-scan")
|
|
19
|
+
.description("Create GitHub issues from scan results")
|
|
20
|
+
.option("--scan-id <id>", "Backend scan ID to create issues from")
|
|
21
|
+
.option("--from-local <path>", "Path to local scan JSON (from rafter scan local --format json)")
|
|
22
|
+
.option("-r, --repo <repo>", "Target GitHub repo (org/repo)")
|
|
23
|
+
.option("-k, --api-key <key>", "Rafter API key (for --scan-id)")
|
|
24
|
+
.option("--no-dedup", "Skip deduplication check")
|
|
25
|
+
.option("--dry-run", "Show issues that would be created without creating them")
|
|
26
|
+
.option("--quiet", "Suppress status messages")
|
|
27
|
+
.action(async (opts) => {
|
|
28
|
+
try {
|
|
29
|
+
await runFromScan(opts);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
console.error(fmt.error(e.message || String(e)));
|
|
33
|
+
process.exit(EXIT_GENERAL_ERROR);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async function runFromScan(opts) {
|
|
38
|
+
if (!opts.scanId && !opts.fromLocal) {
|
|
39
|
+
console.error(fmt.error("Provide --scan-id or --from-local"));
|
|
40
|
+
process.exit(EXIT_GENERAL_ERROR);
|
|
41
|
+
}
|
|
42
|
+
// Resolve target repo
|
|
43
|
+
let repo;
|
|
44
|
+
if (opts.repo) {
|
|
45
|
+
repo = opts.repo;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const detected = detectRepo({});
|
|
49
|
+
repo = detected.repo;
|
|
50
|
+
}
|
|
51
|
+
if (!opts.quiet) {
|
|
52
|
+
console.error(fmt.info(`Target repo: ${repo}`));
|
|
53
|
+
}
|
|
54
|
+
// Build issue drafts from scan results
|
|
55
|
+
let drafts;
|
|
56
|
+
if (opts.scanId) {
|
|
57
|
+
drafts = await draftsFromBackendScan(opts.scanId, opts.apiKey);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
drafts = draftsFromLocalScan(opts.fromLocal);
|
|
61
|
+
}
|
|
62
|
+
if (drafts.length === 0) {
|
|
63
|
+
if (!opts.quiet) {
|
|
64
|
+
console.error(fmt.success("No findings to create issues for"));
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (!opts.quiet) {
|
|
69
|
+
console.error(fmt.info(`Found ${drafts.length} findings`));
|
|
70
|
+
}
|
|
71
|
+
// Dedup against existing open issues
|
|
72
|
+
if (opts.dedup !== false) {
|
|
73
|
+
const existing = listOpenIssues(repo);
|
|
74
|
+
const dupes = findDuplicates(existing, drafts.map((d) => d.fingerprint));
|
|
75
|
+
const before = drafts.length;
|
|
76
|
+
drafts = drafts.filter((d) => !dupes.has(d.fingerprint));
|
|
77
|
+
if (before !== drafts.length && !opts.quiet) {
|
|
78
|
+
console.error(fmt.info(`Skipped ${before - drafts.length} duplicate(s)`));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (drafts.length === 0) {
|
|
82
|
+
if (!opts.quiet) {
|
|
83
|
+
console.error(fmt.success("All findings already have open issues"));
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Dry run — print what would be created
|
|
88
|
+
if (opts.dryRun) {
|
|
89
|
+
console.error(fmt.info(`Would create ${drafts.length} issue(s):`));
|
|
90
|
+
for (const draft of drafts) {
|
|
91
|
+
console.error(` - ${draft.title}`);
|
|
92
|
+
}
|
|
93
|
+
// Output drafts as JSON to stdout for piping
|
|
94
|
+
process.stdout.write(JSON.stringify(drafts, null, 2));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Create issues
|
|
98
|
+
const created = [];
|
|
99
|
+
for (const draft of drafts) {
|
|
100
|
+
try {
|
|
101
|
+
const issue = createIssue({
|
|
102
|
+
repo,
|
|
103
|
+
title: draft.title,
|
|
104
|
+
body: draft.body,
|
|
105
|
+
labels: draft.labels,
|
|
106
|
+
});
|
|
107
|
+
created.push(issue.html_url);
|
|
108
|
+
if (!opts.quiet) {
|
|
109
|
+
console.error(fmt.success(`Created: ${issue.html_url}`));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
console.error(fmt.error(`Failed to create issue: ${e.message}`));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Output created issue URLs to stdout
|
|
117
|
+
if (created.length > 0) {
|
|
118
|
+
process.stdout.write(created.join("\n") + "\n");
|
|
119
|
+
}
|
|
120
|
+
if (!opts.quiet) {
|
|
121
|
+
console.error(fmt.success(`Created ${created.length}/${drafts.length} issue(s)`));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function draftsFromBackendScan(scanId, apiKey) {
|
|
125
|
+
const key = resolveKey(apiKey);
|
|
126
|
+
const { data } = await axios.get(`${API}/static/scan`, {
|
|
127
|
+
params: { scan_id: scanId, format: "json" },
|
|
128
|
+
headers: { "x-api-key": key },
|
|
129
|
+
});
|
|
130
|
+
const vulns = data.vulnerabilities || [];
|
|
131
|
+
return vulns.map(buildFromBackendVulnerability);
|
|
132
|
+
}
|
|
133
|
+
function draftsFromLocalScan(filePath) {
|
|
134
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
135
|
+
const results = JSON.parse(raw);
|
|
136
|
+
const drafts = [];
|
|
137
|
+
for (const result of results) {
|
|
138
|
+
for (const match of result.matches) {
|
|
139
|
+
drafts.push(buildFromLocalMatch(result.file, match));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return drafts;
|
|
143
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rafter issues create from-text — create a GitHub issue from natural language.
|
|
3
|
+
*
|
|
4
|
+
* Input sources: stdin (pipe), --file, --text inline.
|
|
5
|
+
* Parses natural text to extract title, description, labels, severity, affected files.
|
|
6
|
+
* Works as a skill: /rafter-issue in Claude Code / OpenClaw.
|
|
7
|
+
*/
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import { detectRepo } from "../../utils/git.js";
|
|
11
|
+
import { fmt } from "../../utils/formatter.js";
|
|
12
|
+
import { EXIT_GENERAL_ERROR } from "../../utils/api.js";
|
|
13
|
+
import { createIssue } from "./github-client.js";
|
|
14
|
+
export function createFromTextCommand() {
|
|
15
|
+
return new Command("from-text")
|
|
16
|
+
.description("Create a GitHub issue from natural language text (stdin, file, or inline)")
|
|
17
|
+
.option("-r, --repo <repo>", "Target GitHub repo (org/repo)")
|
|
18
|
+
.option("-t, --text <text>", "Inline text to convert to an issue")
|
|
19
|
+
.option("-f, --file <path>", "Read text from file")
|
|
20
|
+
.option("--title <title>", "Override extracted title")
|
|
21
|
+
.option("--labels <labels>", "Comma-separated labels to add")
|
|
22
|
+
.option("--dry-run", "Show parsed issue without creating it")
|
|
23
|
+
.option("--quiet", "Suppress status messages")
|
|
24
|
+
.action(async (opts) => {
|
|
25
|
+
try {
|
|
26
|
+
await runFromText(opts);
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
console.error(fmt.error(e.message || String(e)));
|
|
30
|
+
process.exit(EXIT_GENERAL_ERROR);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function runFromText(opts) {
|
|
35
|
+
// Read input text
|
|
36
|
+
const input = await readInput(opts);
|
|
37
|
+
if (!input.trim()) {
|
|
38
|
+
console.error(fmt.error("No input text provided. Use --text, --file, or pipe via stdin"));
|
|
39
|
+
process.exit(EXIT_GENERAL_ERROR);
|
|
40
|
+
}
|
|
41
|
+
// Resolve target repo
|
|
42
|
+
let repo;
|
|
43
|
+
if (opts.repo) {
|
|
44
|
+
repo = opts.repo;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const detected = detectRepo({});
|
|
48
|
+
repo = detected.repo;
|
|
49
|
+
}
|
|
50
|
+
// Parse the natural text into a structured issue
|
|
51
|
+
const parsed = parseNaturalText(input);
|
|
52
|
+
// Apply overrides
|
|
53
|
+
if (opts.title)
|
|
54
|
+
parsed.title = opts.title;
|
|
55
|
+
if (opts.labels) {
|
|
56
|
+
const extra = opts.labels.split(",").map((l) => l.trim()).filter(Boolean);
|
|
57
|
+
parsed.labels.push(...extra);
|
|
58
|
+
}
|
|
59
|
+
if (!opts.quiet) {
|
|
60
|
+
console.error(fmt.info(`Target repo: ${repo}`));
|
|
61
|
+
console.error(fmt.info(`Title: ${parsed.title}`));
|
|
62
|
+
if (parsed.labels.length) {
|
|
63
|
+
console.error(fmt.info(`Labels: ${parsed.labels.join(", ")}`));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (opts.dryRun) {
|
|
67
|
+
process.stdout.write(JSON.stringify(parsed, null, 2));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const issue = createIssue({
|
|
71
|
+
repo,
|
|
72
|
+
title: parsed.title,
|
|
73
|
+
body: parsed.body,
|
|
74
|
+
labels: parsed.labels,
|
|
75
|
+
});
|
|
76
|
+
if (!opts.quiet) {
|
|
77
|
+
console.error(fmt.success(`Created: ${issue.html_url}`));
|
|
78
|
+
}
|
|
79
|
+
process.stdout.write(issue.html_url + "\n");
|
|
80
|
+
}
|
|
81
|
+
async function readInput(opts) {
|
|
82
|
+
if (opts.text)
|
|
83
|
+
return opts.text;
|
|
84
|
+
if (opts.file)
|
|
85
|
+
return fs.readFileSync(opts.file, "utf-8");
|
|
86
|
+
// Read from stdin if available (piped input)
|
|
87
|
+
if (!process.stdin.isTTY) {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
let data = "";
|
|
90
|
+
process.stdin.setEncoding("utf-8");
|
|
91
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
92
|
+
process.stdin.on("end", () => resolve(data));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return "";
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse natural language text into a structured GitHub issue.
|
|
99
|
+
*
|
|
100
|
+
* Heuristics-based extraction:
|
|
101
|
+
* - First line or sentence → title
|
|
102
|
+
* - Severity keywords → labels
|
|
103
|
+
* - File paths → mentioned in body
|
|
104
|
+
* - Security keywords → security label
|
|
105
|
+
*/
|
|
106
|
+
function parseNaturalText(text) {
|
|
107
|
+
const lines = text.trim().split("\n");
|
|
108
|
+
const labels = [];
|
|
109
|
+
// Extract title: first non-empty line, cleaned up
|
|
110
|
+
let title = "";
|
|
111
|
+
let bodyStart = 0;
|
|
112
|
+
for (let i = 0; i < lines.length; i++) {
|
|
113
|
+
const line = lines[i].trim();
|
|
114
|
+
if (line) {
|
|
115
|
+
// Strip markdown headers
|
|
116
|
+
title = line.replace(/^#+\s*/, "").trim();
|
|
117
|
+
bodyStart = i + 1;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!title) {
|
|
122
|
+
title = "Security issue reported via Rafter CLI";
|
|
123
|
+
}
|
|
124
|
+
// Truncate long titles
|
|
125
|
+
if (title.length > 120) {
|
|
126
|
+
title = title.slice(0, 117) + "...";
|
|
127
|
+
}
|
|
128
|
+
// Build body from remaining text
|
|
129
|
+
const bodyLines = lines.slice(bodyStart);
|
|
130
|
+
let body = bodyLines.join("\n").trim();
|
|
131
|
+
if (!body) {
|
|
132
|
+
body = text.trim();
|
|
133
|
+
}
|
|
134
|
+
// Extract severity from text
|
|
135
|
+
const textLower = text.toLowerCase();
|
|
136
|
+
if (textLower.includes("critical") || textLower.includes("p0")) {
|
|
137
|
+
labels.push("severity:critical");
|
|
138
|
+
}
|
|
139
|
+
else if (textLower.includes("high severity") ||
|
|
140
|
+
textLower.includes("high risk") ||
|
|
141
|
+
textLower.includes("p1")) {
|
|
142
|
+
labels.push("severity:high");
|
|
143
|
+
}
|
|
144
|
+
else if (textLower.includes("medium") || textLower.includes("p2")) {
|
|
145
|
+
labels.push("severity:medium");
|
|
146
|
+
}
|
|
147
|
+
else if (textLower.includes("low") || textLower.includes("p3")) {
|
|
148
|
+
labels.push("severity:low");
|
|
149
|
+
}
|
|
150
|
+
// Detect security-related content
|
|
151
|
+
const securityKeywords = [
|
|
152
|
+
"security",
|
|
153
|
+
"vulnerability",
|
|
154
|
+
"cve",
|
|
155
|
+
"cwe",
|
|
156
|
+
"owasp",
|
|
157
|
+
"secret",
|
|
158
|
+
"credential",
|
|
159
|
+
"token",
|
|
160
|
+
"password",
|
|
161
|
+
"injection",
|
|
162
|
+
"xss",
|
|
163
|
+
"csrf",
|
|
164
|
+
"ssrf",
|
|
165
|
+
"exploit",
|
|
166
|
+
];
|
|
167
|
+
if (securityKeywords.some((kw) => textLower.includes(kw))) {
|
|
168
|
+
labels.push("security");
|
|
169
|
+
}
|
|
170
|
+
// Extract file paths mentioned in text
|
|
171
|
+
const fileRefs = text.match(/(?:^|\s)([a-zA-Z0-9_./-]+\.[a-zA-Z]{1,10})(?::(\d+))?/gm);
|
|
172
|
+
if (fileRefs && fileRefs.length > 0) {
|
|
173
|
+
const files = fileRefs
|
|
174
|
+
.map((f) => f.trim())
|
|
175
|
+
.filter((f) => f.includes("/") || f.includes("."));
|
|
176
|
+
if (files.length > 0) {
|
|
177
|
+
body += `\n\n### Referenced Files\n\n`;
|
|
178
|
+
for (const f of files.slice(0, 10)) {
|
|
179
|
+
body += `- \`${f}\`\n`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
body += `\n\n---\n*Created by [Rafter CLI](https://rafter.so) — security for AI builders*\n`;
|
|
184
|
+
return { title, body, labels: [...new Set(labels)] };
|
|
185
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub API client — wraps `gh` CLI for issue operations.
|
|
3
|
+
*
|
|
4
|
+
* Prefers `gh` CLI (handles auth, rate limits, pagination) over raw REST.
|
|
5
|
+
* Falls back to GITHUB_TOKEN + REST if gh is unavailable.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from "child_process";
|
|
8
|
+
function ghAvailable() {
|
|
9
|
+
try {
|
|
10
|
+
execFileSync("gh", ["--version"], { stdio: "ignore" });
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function createIssue(params) {
|
|
18
|
+
const { repo, title, body, labels } = params;
|
|
19
|
+
if (!ghAvailable()) {
|
|
20
|
+
throw new Error("GitHub CLI (gh) is required. Install: https://cli.github.com");
|
|
21
|
+
}
|
|
22
|
+
const args = [
|
|
23
|
+
"issue",
|
|
24
|
+
"create",
|
|
25
|
+
"--repo",
|
|
26
|
+
repo,
|
|
27
|
+
"--title",
|
|
28
|
+
title,
|
|
29
|
+
"--body",
|
|
30
|
+
body,
|
|
31
|
+
];
|
|
32
|
+
if (labels && labels.length > 0) {
|
|
33
|
+
for (const label of labels) {
|
|
34
|
+
args.push("--label", label);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const result = execFileSync("gh", args, {
|
|
38
|
+
encoding: "utf-8",
|
|
39
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
40
|
+
}).trim();
|
|
41
|
+
// gh issue create returns the issue URL
|
|
42
|
+
const urlMatch = result.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/);
|
|
43
|
+
const number = urlMatch ? parseInt(urlMatch[1], 10) : 0;
|
|
44
|
+
return {
|
|
45
|
+
number,
|
|
46
|
+
title,
|
|
47
|
+
body,
|
|
48
|
+
labels: labels || [],
|
|
49
|
+
html_url: result,
|
|
50
|
+
state: "open",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function listOpenIssues(repo) {
|
|
54
|
+
if (!ghAvailable()) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const result = execFileSync("gh", [
|
|
59
|
+
"issue",
|
|
60
|
+
"list",
|
|
61
|
+
"--repo",
|
|
62
|
+
repo,
|
|
63
|
+
"--state",
|
|
64
|
+
"open",
|
|
65
|
+
"--json",
|
|
66
|
+
"number,title,body,labels,url",
|
|
67
|
+
"--limit",
|
|
68
|
+
"200",
|
|
69
|
+
], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
70
|
+
if (!result)
|
|
71
|
+
return [];
|
|
72
|
+
const issues = JSON.parse(result);
|
|
73
|
+
return issues.map((i) => ({
|
|
74
|
+
number: i.number,
|
|
75
|
+
title: i.title,
|
|
76
|
+
body: i.body || "",
|
|
77
|
+
labels: (i.labels || []).map((l) => l.name),
|
|
78
|
+
html_url: i.url,
|
|
79
|
+
state: "open",
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|