@authtrack/secura 0.1.0

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/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
+ }