secura-cli 0.1.4
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 +229 -0
- package/bin/secura-mcp.mjs +431 -0
- package/bin/secura.mjs +70 -0
- package/package.json +54 -0
- package/src/ansi.mjs +93 -0
- package/src/args.mjs +115 -0
- package/src/client.mjs +95 -0
- package/src/commands/scan.mjs +203 -0
- package/src/config.mjs +33 -0
- package/src/help.mjs +66 -0
- package/src/monitor.mjs +157 -0
- package/src/pack.mjs +119 -0
- package/src/render.mjs +271 -0
- package/src/target.mjs +52 -0
package/src/help.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Help / usage text for the `secura` CLI. */
|
|
2
|
+
|
|
3
|
+
import { bold, dim, cyan } from "./ansi.mjs";
|
|
4
|
+
|
|
5
|
+
export function printHelp() {
|
|
6
|
+
const p = (s = "") => process.stdout.write(s + "\n");
|
|
7
|
+
p();
|
|
8
|
+
p(`${bold("secura")} — AuthTrack static analysis (SAST) from the command line`);
|
|
9
|
+
p();
|
|
10
|
+
p("Streams a five-scanner scan (Semgrep, Bearer, OSV-Scanner, Gitleaks, CodeQL)");
|
|
11
|
+
p("from the AuthTrack backend and renders it live in your terminal.");
|
|
12
|
+
p();
|
|
13
|
+
p(bold("USAGE"));
|
|
14
|
+
p(` secura scan [target] [options]`);
|
|
15
|
+
p(` secura version`);
|
|
16
|
+
p(` secura --help`);
|
|
17
|
+
p();
|
|
18
|
+
p(bold("TARGETS"));
|
|
19
|
+
p(` ${cyan(".")} the current directory (default)`);
|
|
20
|
+
p(` ${cyan("./path/to/repo")} a local folder`);
|
|
21
|
+
p(` ${cyan("owner/repo")} a public GitHub repo (shorthand)`);
|
|
22
|
+
p(` ${cyan("https://github.com/o/r")} any public Git URL (cloned by the backend)`);
|
|
23
|
+
p();
|
|
24
|
+
p(bold("COMMON"));
|
|
25
|
+
p(` secura scan .`);
|
|
26
|
+
p(` secura scan owner/repo --fast`);
|
|
27
|
+
p(` secura scan . --fail-on high ${dim("# CI gate")}`);
|
|
28
|
+
p(` secura scan . --json report.json`);
|
|
29
|
+
p();
|
|
30
|
+
p(`Run ${cyan("secura scan --help")} for every flag.`);
|
|
31
|
+
p();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function printScanHelp() {
|
|
35
|
+
const p = (s = "") => process.stdout.write(s + "\n");
|
|
36
|
+
p();
|
|
37
|
+
p(`${bold("secura scan")} [target] [options]`);
|
|
38
|
+
p();
|
|
39
|
+
p("Fans out to five scanners at once, merges and deduplicates their findings,");
|
|
40
|
+
p("retrieves similar known-vulnerable patterns, then has an LLM confirm or rule");
|
|
41
|
+
p("out each finding and write a patch for the confirmed ones.");
|
|
42
|
+
p();
|
|
43
|
+
p(bold("OPTIONS"));
|
|
44
|
+
p(` -f, --fast Skip CodeQL (its DB build dominates the run).`);
|
|
45
|
+
p(` -l, --language <lang> CodeQL language (javascript, python, java, go,`);
|
|
46
|
+
p(` ruby, csharp, cpp). Auto-detected when omitted.`);
|
|
47
|
+
p(` -t, --max-triage <n> Cap findings sent to LLM triage (default 25).`);
|
|
48
|
+
p(` --fail-on <sev> Exit 1 if a confirmed finding is ≥ this severity`);
|
|
49
|
+
p(` (critical|high|medium|low). For CI gates.`);
|
|
50
|
+
p(` -o, --json <path> Write the full report as JSON to <path>.`);
|
|
51
|
+
p(` --max-findings <n> How many confirmed findings to print (default 20).`);
|
|
52
|
+
p(` --show-fixes Print the generated fix diffs in full.`);
|
|
53
|
+
p(` -q, --quiet Suppress the live view; print only the report.`);
|
|
54
|
+
p();
|
|
55
|
+
p(bold("BACKEND"));
|
|
56
|
+
p(` --api <url> Backend base URL`);
|
|
57
|
+
p(` (default $SECURA_API or http://localhost:8000/api/v1).`);
|
|
58
|
+
p(` --token <token> Bearer token, if the backend requires one`);
|
|
59
|
+
p(` (default $SECURA_TOKEN).`);
|
|
60
|
+
p(` --upload Package the local dir and upload it, even for a`);
|
|
61
|
+
p(` local backend. Automatic for a remote --api.`);
|
|
62
|
+
p();
|
|
63
|
+
p(bold("EXIT CODES"));
|
|
64
|
+
p(` 0 clean 1 --fail-on breached 2 bad target / failure 130 interrupted`);
|
|
65
|
+
p();
|
|
66
|
+
}
|
package/src/monitor.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port of ScanMonitor (backend/app/cli/main.py): fold each `node_update` event
|
|
3
|
+
* into the running totals the terminal view needs.
|
|
4
|
+
*
|
|
5
|
+
* The graph streams one update per completed node, and the triage loop streams
|
|
6
|
+
* one per finding, so state is accumulated here rather than re-derived from any
|
|
7
|
+
* single partial snapshot — identical semantics to the Python monitor.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Mirrors frontend/src/lib/static-analysis.ts and main.py so the CLI and the
|
|
11
|
+
// web UI describe the same five engines in the same words.
|
|
12
|
+
export const TOOL_ORDER = ["semgrep", "bearer", "osv-scanner", "gitleaks", "codeql"];
|
|
13
|
+
|
|
14
|
+
export const TOOL_KIND = {
|
|
15
|
+
semgrep: "SAST",
|
|
16
|
+
bearer: "SAST + privacy",
|
|
17
|
+
"osv-scanner": "SCA",
|
|
18
|
+
gitleaks: "Secrets",
|
|
19
|
+
codeql: "Taint analysis",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Backend graph node name → the tool whose row it fills in.
|
|
23
|
+
const NODE_TO_TOOL = {
|
|
24
|
+
run_semgrep: "semgrep",
|
|
25
|
+
run_bearer: "bearer",
|
|
26
|
+
run_osv_scanner: "osv-scanner",
|
|
27
|
+
run_gitleaks: "gitleaks",
|
|
28
|
+
run_codeql: "codeql",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
32
|
+
|
|
33
|
+
export const STAGE_LABELS = {
|
|
34
|
+
dispatch: "Resolving target",
|
|
35
|
+
scan: "Running 5 scanners in parallel",
|
|
36
|
+
merge: "Merging and deduplicating",
|
|
37
|
+
rag: "Retrieving similar known-vulnerable patterns",
|
|
38
|
+
triage: "AI triage",
|
|
39
|
+
fixes: "Generating fixes",
|
|
40
|
+
report: "Building report",
|
|
41
|
+
done: "Complete",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export class ScanMonitor {
|
|
45
|
+
constructor(target, includeCodeql) {
|
|
46
|
+
this.target = target;
|
|
47
|
+
this.includeCodeql = includeCodeql;
|
|
48
|
+
this.started = Date.now();
|
|
49
|
+
this.stage = "dispatch";
|
|
50
|
+
this.language = "detecting…";
|
|
51
|
+
|
|
52
|
+
this.tools = {};
|
|
53
|
+
for (const name of TOOL_ORDER) {
|
|
54
|
+
this.tools[name] = { state: "pending", count: 0, duration: 0, note: "", sample: false };
|
|
55
|
+
}
|
|
56
|
+
if (!includeCodeql) {
|
|
57
|
+
this.tools.codeql = {
|
|
58
|
+
state: "skipped", count: 0, duration: 0, note: "skipped (--fast)", sample: false,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.mergeStats = {};
|
|
63
|
+
this.safePatterns = 0;
|
|
64
|
+
this.ragMatched = null;
|
|
65
|
+
this.ragTotal = 0;
|
|
66
|
+
|
|
67
|
+
this.triageDone = 0;
|
|
68
|
+
this.triageTotal = 0;
|
|
69
|
+
this.confirmed = 0;
|
|
70
|
+
this.discarded = 0;
|
|
71
|
+
|
|
72
|
+
this.fixes = 0;
|
|
73
|
+
this.report = {};
|
|
74
|
+
this.errors = [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
startScanners() {
|
|
78
|
+
for (const row of Object.values(this.tools)) {
|
|
79
|
+
if (row.state === "pending") row.state = "running";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
elapsedSeconds() {
|
|
84
|
+
return (Date.now() - this.started) / 1000;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Fold one `node_update` into the running totals. */
|
|
88
|
+
ingest(node, state) {
|
|
89
|
+
const s = state || {};
|
|
90
|
+
|
|
91
|
+
if (node === "dispatch") {
|
|
92
|
+
this.language = s.codeql_language || "auto";
|
|
93
|
+
this.stage = "scan";
|
|
94
|
+
this.startScanners();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (node in NODE_TO_TOOL) {
|
|
99
|
+
const tool = NODE_TO_TOOL[node];
|
|
100
|
+
const status = (s.tool_status || {})[tool] || {};
|
|
101
|
+
const skipped = String(status.note || "").toLowerCase().includes("skipped");
|
|
102
|
+
this.tools[tool] = {
|
|
103
|
+
state: skipped ? "skipped" : status.ok ? "ok" : "failed",
|
|
104
|
+
count: status.finding_count || 0,
|
|
105
|
+
duration: status.duration_s || 0,
|
|
106
|
+
note: status.note || "",
|
|
107
|
+
sample: Boolean(status.used_sample),
|
|
108
|
+
};
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (node === "merge_findings") {
|
|
113
|
+
this.stage = "merge";
|
|
114
|
+
this.mergeStats = s.merge_stats || {};
|
|
115
|
+
this.safePatterns = (s.safe_patterns || []).length;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (node === "retrieve_context") {
|
|
120
|
+
this.stage = "rag";
|
|
121
|
+
const findings = s.merged_findings || [];
|
|
122
|
+
this.ragTotal = findings.length;
|
|
123
|
+
this.ragMatched = findings.filter((f) => (f.context || []).length).length;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (node === "triage") {
|
|
128
|
+
this.stage = "triage";
|
|
129
|
+
const progress = s.triage_progress || {};
|
|
130
|
+
if (progress.done !== undefined && progress.done !== null) this.triageDone = progress.done;
|
|
131
|
+
if (progress.total !== undefined && progress.total !== null) this.triageTotal = progress.total;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (node === "confirm_finding") {
|
|
136
|
+
this.confirmed += (s.triaged_findings || []).length || 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (node === "discard_finding") {
|
|
141
|
+
this.discarded += (s.false_positives || []).length || 1;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (node === "fix_generator") {
|
|
146
|
+
this.stage = "fixes";
|
|
147
|
+
this.fixes = (s.fixes || []).length;
|
|
148
|
+
this.errors.push(...(s.errors || []));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (node === "report_builder") {
|
|
153
|
+
this.stage = "report";
|
|
154
|
+
this.report = s.static_report || {};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/pack.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package a local directory as a gzipped tarball for upload to a remote backend.
|
|
3
|
+
*
|
|
4
|
+
* The tree is walked in Node — skipping the same directories/files the scanners
|
|
5
|
+
* ignore, and never following symlinks — and the resulting relative file list is
|
|
6
|
+
* handed to the system `tar` via `-T`. Walking ourselves (rather than leaning on
|
|
7
|
+
* tar's `--exclude` globbing, which differs between GNU tar and bsdtar) keeps the
|
|
8
|
+
* archive contents identical on Windows, macOS and Linux, and guarantees only
|
|
9
|
+
* regular files under the root make it in.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
// CLI-side cap on the packaged archive; the backend enforces its own uncompressed
|
|
18
|
+
// limits on extraction. A repo bigger than this should be scanned as a Git URL.
|
|
19
|
+
export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100 MB
|
|
20
|
+
|
|
21
|
+
// Mirrors EXCLUDE_DIRS / EXCLUDE_GLOBS in
|
|
22
|
+
// backend/app/agents/static_analysis/tools.py.
|
|
23
|
+
const EXCLUDE_DIRS = new Set([
|
|
24
|
+
"node_modules", ".git", ".venv", "venv", "__pycache__", "dist", "build",
|
|
25
|
+
".next", "out", "coverage", "vendor", "site-packages", "target",
|
|
26
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache", ".turbo", ".cache",
|
|
27
|
+
]);
|
|
28
|
+
const EXCLUDE_SUFFIXES = [".min.js", ".min.css", ".map", ".lock", ".bundle.js"];
|
|
29
|
+
|
|
30
|
+
const isExcludedFile = (name) => EXCLUDE_SUFFIXES.some((s) => name.endsWith(s));
|
|
31
|
+
|
|
32
|
+
export function hasTar() {
|
|
33
|
+
try {
|
|
34
|
+
const r = spawnSync("tar", ["--version"], { stdio: "ignore" });
|
|
35
|
+
return !r.error && r.status === 0;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function walk(root) {
|
|
42
|
+
const files = [];
|
|
43
|
+
const stack = [""]; // relative paths, "" = root
|
|
44
|
+
while (stack.length) {
|
|
45
|
+
const rel = stack.pop();
|
|
46
|
+
const abs = rel ? path.join(root, rel) : root;
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
50
|
+
} catch {
|
|
51
|
+
continue; // unreadable directory — skip it
|
|
52
|
+
}
|
|
53
|
+
for (const ent of entries) {
|
|
54
|
+
if (ent.isSymbolicLink()) continue; // never archive links
|
|
55
|
+
const childRel = rel ? `${rel}/${ent.name}` : ent.name;
|
|
56
|
+
if (ent.isDirectory()) {
|
|
57
|
+
if (!EXCLUDE_DIRS.has(ent.name)) stack.push(childRel);
|
|
58
|
+
} else if (ent.isFile() && !isExcludedFile(ent.name)) {
|
|
59
|
+
files.push(childRel); // forward-slashed; tar accepts these on Windows too
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return files;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function safeUnlink(p) {
|
|
67
|
+
try {
|
|
68
|
+
fs.unlinkSync(p);
|
|
69
|
+
} catch {
|
|
70
|
+
/* already gone */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the tarball. Returns { path, fileCount, cleanup }. `cleanup()` removes
|
|
76
|
+
* the archive and must be called by the caller once the upload finishes.
|
|
77
|
+
*/
|
|
78
|
+
export function packDir(dir) {
|
|
79
|
+
if (!hasTar()) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
"`tar` was not found on your PATH, so local code can't be packaged for a remote backend.\n" +
|
|
82
|
+
" Fixes: install tar (bundled with Windows 10 1803+, macOS and Linux),\n" +
|
|
83
|
+
" scan a public Git repo URL instead (the backend clones it),\n" +
|
|
84
|
+
" or run the backend on this machine and drop --upload."
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const files = walk(dir);
|
|
89
|
+
if (files.length === 0) {
|
|
90
|
+
throw new Error(`Nothing to scan under ${dir} (it's empty, or every file is excluded).`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const stamp = `${process.pid}-${Date.now()}`;
|
|
94
|
+
const listPath = path.join(os.tmpdir(), `secura-files-${stamp}.txt`);
|
|
95
|
+
const archivePath = path.join(os.tmpdir(), `secura-upload-${stamp}.tar.gz`);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
fs.writeFileSync(listPath, files.join("\n") + "\n", "utf8");
|
|
99
|
+
// -C dir + relative names → the paths archive at the tarball root.
|
|
100
|
+
const r = spawnSync("tar", ["-czf", archivePath, "-C", dir, "-T", listPath], {
|
|
101
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
102
|
+
});
|
|
103
|
+
if (r.error) throw r.error;
|
|
104
|
+
if (r.status !== 0) {
|
|
105
|
+
const stderr = r.stderr ? r.stderr.toString().trim() : "";
|
|
106
|
+
throw new Error(`tar exited ${r.status}${stderr ? `: ${stderr}` : ""}`);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
path: archivePath,
|
|
110
|
+
fileCount: files.length,
|
|
111
|
+
cleanup: () => safeUnlink(archivePath),
|
|
112
|
+
};
|
|
113
|
+
} catch (err) {
|
|
114
|
+
safeUnlink(archivePath);
|
|
115
|
+
throw new Error(`Failed to package ${dir}: ${err.message}`);
|
|
116
|
+
} finally {
|
|
117
|
+
safeUnlink(listPath); // the list is consumed by tar; drop it either way
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/render.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal rendering: the in-place live view during a run, and the final report.
|
|
3
|
+
*
|
|
4
|
+
* Ports renderable() / _tool_table() / _print_report() from
|
|
5
|
+
* backend/app/cli/main.py — same layout, same severity colours, same wording —
|
|
6
|
+
* but with hand-rolled ANSI instead of Rich, so the package stays dependency-free.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
bold, dim, red, green, yellow, blue, magenta, cyan, gray,
|
|
11
|
+
severityText, clip, visibleLength, hideCursor, showCursor, colorEnabled,
|
|
12
|
+
} from "./ansi.mjs";
|
|
13
|
+
import { TOOL_ORDER, TOOL_KIND, STAGE_LABELS } from "./monitor.mjs";
|
|
14
|
+
|
|
15
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
16
|
+
|
|
17
|
+
const termWidth = () => process.stdout.columns || 90;
|
|
18
|
+
const reportWidth = () => Math.max(40, Math.min(termWidth(), 96));
|
|
19
|
+
|
|
20
|
+
const padEnd = (s, n) => String(s).padEnd(n);
|
|
21
|
+
const padStart = (s, n) => String(s).padStart(n);
|
|
22
|
+
|
|
23
|
+
// ── Live view ────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function toolRow(name, row, frame) {
|
|
26
|
+
let mark;
|
|
27
|
+
switch (row.state) {
|
|
28
|
+
case "running": mark = blue(SPINNER[frame % SPINNER.length]); break;
|
|
29
|
+
case "ok": mark = green("✔"); break;
|
|
30
|
+
case "failed": mark = yellow("!"); break;
|
|
31
|
+
case "skipped": mark = dim("–"); break;
|
|
32
|
+
default: mark = dim("·");
|
|
33
|
+
}
|
|
34
|
+
const count = row.state === "pending" || row.state === "running" ? "—" : String(row.count);
|
|
35
|
+
const duration = row.duration ? `${row.duration.toFixed(1)}s` : "";
|
|
36
|
+
let note = row.note || "";
|
|
37
|
+
if (row.sample) note = note ? `sample data — ${note}` : "using sample data";
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
`${mark} ${bold(padEnd(name, 11))} ${dim(padEnd(TOOL_KIND[name], 15))} ` +
|
|
41
|
+
`${padStart(count, 4)} ${dim(padStart(duration, 6))} ${dim(note)}`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The multi-line live frame; mirrors ScanMonitor.renderable(). */
|
|
46
|
+
export function renderLive(monitor, frame) {
|
|
47
|
+
const width = reportWidth();
|
|
48
|
+
const lines = [];
|
|
49
|
+
|
|
50
|
+
lines.push(bold("Static Analysis") + dim(" · 5 scanners → merge → RAG triage → fixes"));
|
|
51
|
+
lines.push(`${dim("target ")}${cyan(monitor.target)}`);
|
|
52
|
+
lines.push(`${dim("language ")}${monitor.language}`);
|
|
53
|
+
lines.push(
|
|
54
|
+
`${dim("stage ")}${bold(blue(STAGE_LABELS[monitor.stage] || monitor.stage))}` +
|
|
55
|
+
dim(` (${Math.round(monitor.elapsedSeconds())}s elapsed)`)
|
|
56
|
+
);
|
|
57
|
+
lines.push("");
|
|
58
|
+
|
|
59
|
+
for (const name of TOOL_ORDER) {
|
|
60
|
+
lines.push(toolRow(name, monitor.tools[name], frame));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (monitor.mergeStats && Object.keys(monitor.mergeStats).length) {
|
|
64
|
+
const s = monitor.mergeStats;
|
|
65
|
+
lines.push(
|
|
66
|
+
`${dim("merge ")}${s.total_raw || 0} raw ${dim("→")} ${bold(cyan(`${s.total_after_dedup || 0} unique`))}` +
|
|
67
|
+
dim(` (${s.duplicates_removed || 0} duplicates removed, ${s.multi_tool_confirmed || 0} multi-tool, ${s.with_taint_path || 0} with taint path)`)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (monitor.safePatterns) {
|
|
71
|
+
lines.push(green(`controls ${monitor.safePatterns} existing security control(s) detected`));
|
|
72
|
+
}
|
|
73
|
+
if (monitor.ragMatched !== null) {
|
|
74
|
+
lines.push(magenta(`rag ${monitor.ragMatched}/${monitor.ragTotal} finding(s) matched a known-vulnerable pattern`));
|
|
75
|
+
}
|
|
76
|
+
if (monitor.triageTotal) {
|
|
77
|
+
lines.push(
|
|
78
|
+
`${dim("triage ")}${blue(bar(monitor.triageDone, monitor.triageTotal))} ${monitor.triageDone}/${monitor.triageTotal}` +
|
|
79
|
+
`${dim(" confirmed ")}${bold(red(String(monitor.confirmed)))}${dim(" ruled out ")}${bold(gray(String(monitor.discarded)))}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (monitor.fixes) {
|
|
83
|
+
lines.push(green(`fixes ${monitor.fixes} remediation(s) generated`));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return lines.map((l) => clip(l, width)).join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function bar(done, total, width = 24) {
|
|
90
|
+
if (total <= 0) return "░".repeat(width);
|
|
91
|
+
const filled = Math.max(0, Math.min(width, Math.round((width * done) / total)));
|
|
92
|
+
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Redraws a block of text in place by moving the cursor up over the previous
|
|
97
|
+
* frame and clearing to the end of the screen. No-op when stdout is not a TTY
|
|
98
|
+
* (piped / CI) — the run then prints nothing until the final report.
|
|
99
|
+
*/
|
|
100
|
+
export class LiveView {
|
|
101
|
+
constructor(stream = process.stdout) {
|
|
102
|
+
this.stream = stream;
|
|
103
|
+
this.lastLines = 0;
|
|
104
|
+
this.active = colorEnabled && Boolean(stream.isTTY);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
start() {
|
|
108
|
+
if (this.active) hideCursor();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
render(text) {
|
|
112
|
+
if (!this.active) return;
|
|
113
|
+
if (this.lastLines > 0) this.stream.write(`\x1b[${this.lastLines}A`); // cursor up
|
|
114
|
+
this.stream.write("\x1b[0J"); // clear to end of screen
|
|
115
|
+
this.stream.write(text + "\n");
|
|
116
|
+
this.lastLines = text.split("\n").length;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
stop() {
|
|
120
|
+
if (this.active) showCursor();
|
|
121
|
+
this.lastLines = 0;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Final report ───────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/** A rounded-border panel with a title, wrapping the body to fit. */
|
|
128
|
+
function panel(title, body, width = reportWidth()) {
|
|
129
|
+
const inner = width - 4;
|
|
130
|
+
const bodyLines = [];
|
|
131
|
+
for (const para of String(body).split("\n")) {
|
|
132
|
+
const wrapped = wrapText(para, inner);
|
|
133
|
+
for (const w of wrapped) bodyLines.push(w);
|
|
134
|
+
}
|
|
135
|
+
const top = `╭─ ${bold(title)} ${"─".repeat(Math.max(0, width - visibleLength(title) - 5))}╮`;
|
|
136
|
+
const bottom = `╰${"─".repeat(width - 2)}╯`;
|
|
137
|
+
const mid = bodyLines.map((l) => `│ ${l}${" ".repeat(Math.max(0, inner - visibleLength(l)))} │`);
|
|
138
|
+
return [top, ...mid, bottom].join("\n");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function wrapText(text, width) {
|
|
142
|
+
if (width <= 0) return [text];
|
|
143
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
144
|
+
if (words.length === 0) return [""];
|
|
145
|
+
const lines = [];
|
|
146
|
+
let line = "";
|
|
147
|
+
for (const word of words) {
|
|
148
|
+
if (!line) {
|
|
149
|
+
line = word;
|
|
150
|
+
} else if (visibleLength(line) + 1 + visibleLength(word) <= width) {
|
|
151
|
+
line += ` ${word}`;
|
|
152
|
+
} else {
|
|
153
|
+
lines.push(line);
|
|
154
|
+
line = word;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (line) lines.push(line);
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const out = (s = "") => process.stdout.write(s + "\n");
|
|
162
|
+
|
|
163
|
+
/** Print the finished report: summary, per-tool accounting, then findings. */
|
|
164
|
+
export function printReport(report, { showFixes = false, maxFindings = 20 } = {}) {
|
|
165
|
+
if (!report || Object.keys(report).length === 0) {
|
|
166
|
+
out(yellow("No report was produced."));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
out();
|
|
171
|
+
const repo = report.source_repo || "";
|
|
172
|
+
const scanned = repo || report.target_path || "";
|
|
173
|
+
if (scanned) out(dim("scanned ") + cyan(scanned) + (repo ? dim(" (cloned)") : ""));
|
|
174
|
+
out(panel("Summary", report.summary || "No summary available."));
|
|
175
|
+
|
|
176
|
+
// ── Headline counters ──
|
|
177
|
+
out();
|
|
178
|
+
const counter = (label, value) => out(` ${dim(padEnd(label, 30))}${value}`);
|
|
179
|
+
counter("unique findings", String(report.total_after_merge || 0));
|
|
180
|
+
counter("confirmed", red(String(report.confirmed_count || 0)));
|
|
181
|
+
counter("ruled out as false positives", String(report.ruled_out_count || 0));
|
|
182
|
+
if (report.not_triaged_count) counter("not triaged (cap reached)", String(report.not_triaged_count));
|
|
183
|
+
counter("fixes generated", green(String(report.fixes_count || 0)));
|
|
184
|
+
counter("with a CodeQL taint path", String(report.with_taint_path_count || 0));
|
|
185
|
+
|
|
186
|
+
// ── Severity breakdown of confirmed findings ──
|
|
187
|
+
const severities = report.severity_breakdown || {};
|
|
188
|
+
if (Object.keys(severities).length) {
|
|
189
|
+
out();
|
|
190
|
+
out(bold("Confirmed by severity"));
|
|
191
|
+
for (const name of ["critical", "high", "medium", "low", "info"]) {
|
|
192
|
+
if (severities[name]) out(` ${padEnd(severityText(name), 18)} ${severities[name]}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Per-tool contribution ──
|
|
197
|
+
const perTool = report.findings_per_tool || {};
|
|
198
|
+
if (Object.keys(perTool).length) {
|
|
199
|
+
out();
|
|
200
|
+
out(bold("Per-tool contribution"));
|
|
201
|
+
out(dim(` ${padEnd("scanner", 13)}${padStart("raw", 5)}${padStart("after merge", 14)}${padStart("time", 8)}`));
|
|
202
|
+
for (const name of TOOL_ORDER) {
|
|
203
|
+
const e = perTool[name];
|
|
204
|
+
if (!e) continue;
|
|
205
|
+
const t = `${(e.duration_s || 0).toFixed(1)}s`;
|
|
206
|
+
out(` ${padEnd(name, 13)}${padStart(String(e.raw || 0), 5)}${padStart(String(e.after_merge || 0), 14)}${dim(padStart(t, 8))}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Confirmed findings ──
|
|
211
|
+
const confirmed = report.confirmed_findings || [];
|
|
212
|
+
if (confirmed.length) {
|
|
213
|
+
out();
|
|
214
|
+
out(bold("Confirmed findings"));
|
|
215
|
+
for (const f of confirmed.slice(0, maxFindings)) {
|
|
216
|
+
const evidence = f.taint_path
|
|
217
|
+
? "taint path"
|
|
218
|
+
: f.multi_tool_confirmed
|
|
219
|
+
? "multi-tool"
|
|
220
|
+
: "single tool";
|
|
221
|
+
out(
|
|
222
|
+
` ${severityText(f.final_severity || f.severity)} ${bold(f.category || "")} ` +
|
|
223
|
+
`${cyan(`${f.file}:${f.line}`)} ${dim(`[${evidence}]`)}`
|
|
224
|
+
);
|
|
225
|
+
if (f.rule_id) out(` ${dim(f.rule_id)}`);
|
|
226
|
+
const reason = (f.triage_reason || "").trim();
|
|
227
|
+
if (reason) out(` ${reason}`);
|
|
228
|
+
if (f.taint_path) {
|
|
229
|
+
const chain = f.taint_path.map((step) => `${step.file}:${step.line}`).join(" → ");
|
|
230
|
+
out(` ${green("flow:")} ${dim(chain)}`);
|
|
231
|
+
}
|
|
232
|
+
out();
|
|
233
|
+
}
|
|
234
|
+
if (confirmed.length > maxFindings) {
|
|
235
|
+
out(dim(` …and ${confirmed.length - maxFindings} more (raise with --max-findings)`));
|
|
236
|
+
out();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Existing security controls ──
|
|
241
|
+
const safe = report.safe_patterns || [];
|
|
242
|
+
if (safe.length) {
|
|
243
|
+
out(bold(green("Security controls already in place")));
|
|
244
|
+
for (const p of safe) {
|
|
245
|
+
out(` ${green("✔")} ${p.description || ""}`);
|
|
246
|
+
out(` ${dim(`${p.path}:${p.line}`)}`);
|
|
247
|
+
}
|
|
248
|
+
out();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Fixes ──
|
|
252
|
+
const fixes = report.fixes || [];
|
|
253
|
+
if (fixes.length && showFixes) {
|
|
254
|
+
out(bold("Generated fixes"));
|
|
255
|
+
for (const fix of fixes) {
|
|
256
|
+
out(` ${cyan(`${fix.file}:${fix.line}`)} ${dim(`(${fix.rule_id}, grounded in ${fix.grounded_in})`)}`);
|
|
257
|
+
out(panel("diff", fix.diff_text || ""));
|
|
258
|
+
if (fix.explanation) out(` ${fix.explanation}\n`);
|
|
259
|
+
}
|
|
260
|
+
} else if (fixes.length) {
|
|
261
|
+
out(dim(`${fixes.length} fix(es) generated — rerun with --show-fixes to print the diffs.`));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Warnings ──
|
|
265
|
+
const errors = report.errors || [];
|
|
266
|
+
if (errors.length) {
|
|
267
|
+
out();
|
|
268
|
+
out(yellow("Warnings"));
|
|
269
|
+
for (const e of errors) out(` ${yellow("•")} ${dim(e)}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
package/src/target.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify a scan target as a Git repo or a local path.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors is_git_url / normalize_git_url in
|
|
5
|
+
* backend/app/agents/static_analysis/tools.py (and the isGitUrl already ported
|
|
6
|
+
* into frontend/src/components/scan/cli-hint.tsx) so the CLI, the web UI and the
|
|
7
|
+
* backend agree on what counts as a repo URL.
|
|
8
|
+
*
|
|
9
|
+
* A URL target is sent to the backend verbatim as `target_path`; the graph's
|
|
10
|
+
* dispatch node shallow-clones and normalises it (including `owner/repo`
|
|
11
|
+
* shorthand), exactly as the Python CLI relies on.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
const GIT_URL_RE = /^(?:https?:\/\/|git@|ssh:\/\/|git:\/\/)[\w.@:\/~-]+?(?:\.git)?\/?$/i;
|
|
19
|
+
const GIT_SHORTHAND_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
20
|
+
|
|
21
|
+
export function isGitUrl(target) {
|
|
22
|
+
if (!target) return false;
|
|
23
|
+
const t = String(target).trim();
|
|
24
|
+
if (!t) return false;
|
|
25
|
+
// An existing local path is never a URL, even if it looks like owner/repo.
|
|
26
|
+
try {
|
|
27
|
+
if (fs.existsSync(t)) return false;
|
|
28
|
+
} catch {
|
|
29
|
+
/* ignore fs errors — fall through to the pattern tests */
|
|
30
|
+
}
|
|
31
|
+
if (GIT_URL_RE.test(t)) return true;
|
|
32
|
+
return GIT_SHORTHAND_RE.test(t) && !/^[.\/~]/.test(t);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function expandHome(p) {
|
|
36
|
+
if (p === "~") return os.homedir();
|
|
37
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) return path.join(os.homedir(), p.slice(2));
|
|
38
|
+
return p;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* → { kind: "url", value, display } for a repo (value = raw target, sent as-is),
|
|
43
|
+
* → { kind: "path", value, display } for a local dir (value = absolute path).
|
|
44
|
+
*/
|
|
45
|
+
export function classifyTarget(rawTarget) {
|
|
46
|
+
const raw = String(rawTarget ?? ".").trim() || ".";
|
|
47
|
+
if (isGitUrl(raw)) {
|
|
48
|
+
return { kind: "url", value: raw, display: raw };
|
|
49
|
+
}
|
|
50
|
+
const resolved = path.resolve(expandHome(raw));
|
|
51
|
+
return { kind: "path", value: resolved, display: raw };
|
|
52
|
+
}
|