@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/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # @authtrack/secura
2
+
3
+ **AuthTrack static analysis (SAST) from the command line.**
4
+
5
+ `secura` points five independent engines — [Semgrep], [Bearer], [OSV-Scanner],
6
+ [Gitleaks] and [CodeQL] — at a repository, merges and deduplicates their
7
+ findings, retrieves similar known-vulnerable patterns, then has an LLM confirm
8
+ or rule out each finding and write a patch for the confirmed ones. It streams
9
+ the whole run live in your terminal and prints a report at the end.
10
+
11
+ The scanning runs on the **AuthTrack backend**; this CLI is a thin, zero-dependency
12
+ client that talks to it. You don't need Python, and you don't need the scanners
13
+ installed locally.
14
+
15
+ [Semgrep]: https://semgrep.dev
16
+ [Bearer]: https://www.bearer.com
17
+ [OSV-Scanner]: https://google.github.io/osv-scanner/
18
+ [Gitleaks]: https://github.com/gitleaks/gitleaks
19
+ [CodeQL]: https://codeql.github.com
20
+
21
+ ---
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm install -g @authtrack/secura
27
+ ```
28
+
29
+ Or run it once, without installing:
30
+
31
+ ```bash
32
+ npx @authtrack/secura scan .
33
+ ```
34
+
35
+ Requires **Node ≥ 18**. No build step, no native modules.
36
+
37
+ ## Quick start
38
+
39
+ ```bash
40
+ # Scan the current directory
41
+ secura scan .
42
+
43
+ # Scan a public GitHub repo (the backend clones it)
44
+ secura scan owner/repo
45
+ secura scan https://github.com/owner/repo
46
+
47
+ # Fast pass — skip CodeQL (its database build dominates the run)
48
+ secura scan . --fast
49
+
50
+ # CI gate: exit non-zero if anything HIGH or worse is confirmed
51
+ secura scan . --fail-on high
52
+
53
+ # Save the full machine-readable report
54
+ secura scan . --json report.json
55
+ ```
56
+
57
+ ## Targets
58
+
59
+ | Target | What happens |
60
+ | --- | --- |
61
+ | `.` or `./path/to/repo` | A local folder. Sent to the backend to scan (see [Backend](#backend)). |
62
+ | `owner/repo` | Public GitHub shorthand. The backend shallow-clones it, scans, then deletes it. |
63
+ | `https://github.com/owner/repo`, `git@…`, `ssh://…` | Any public Git URL. Cloned by the backend. |
64
+
65
+ For a local path, how the code reaches the backend depends on where the backend runs:
66
+
67
+ - **Local backend** (the default, `http://localhost:8000`): the absolute path is
68
+ sent as-is and scanned in place — nothing is copied.
69
+ - **Remote backend** (a `--api` that isn't localhost), or **`--upload`**: the
70
+ directory is packaged into a `tar.gz` (excluding `node_modules`, `.git`, build
71
+ output, lockfiles, minified assets, …) and uploaded. Requires `tar` on your
72
+ PATH (bundled with Windows 10 1803+, macOS and Linux). If `tar` is missing,
73
+ scan a Git URL instead.
74
+
75
+ ## Options
76
+
77
+ ```
78
+ -f, --fast Skip CodeQL (its DB build dominates the run).
79
+ -l, --language <lang> CodeQL language (javascript, python, java, go, ruby,
80
+ csharp, cpp). Auto-detected when omitted.
81
+ -t, --max-triage <n> Cap findings sent to LLM triage (default 25).
82
+ --fail-on <sev> Exit 1 if a confirmed finding is >= this severity
83
+ (critical|high|medium|low). For CI gates.
84
+ -o, --json <path> Write the full report as JSON to <path>.
85
+ --max-findings <n> How many confirmed findings to print (default 20).
86
+ --show-fixes Print the generated fix diffs in full.
87
+ -q, --quiet Suppress the live view; print only the report.
88
+ --api <url> Backend base URL (default $SECURA_API or
89
+ http://localhost:8000/api/v1).
90
+ --token <token> Bearer token, if the backend requires one
91
+ (default $SECURA_TOKEN).
92
+ --upload Package and upload the local dir even for a local
93
+ backend (automatic for a remote --api).
94
+ ```
95
+
96
+ ### Exit codes
97
+
98
+ | Code | Meaning |
99
+ | --- | --- |
100
+ | `0` | Completed; no `--fail-on` breach. |
101
+ | `1` | `--fail-on` gate breached (a confirmed finding at or above the threshold). |
102
+ | `2` | Bad target, or the scan failed. |
103
+ | `130` | Interrupted (Ctrl-C). |
104
+
105
+ ## Backend
106
+
107
+ `secura` needs a reachable AuthTrack backend. Point it at one with `--api` or the
108
+ `SECURA_API` environment variable (matching the web app's `NEXT_PUBLIC_API_URL`):
109
+
110
+ ```bash
111
+ export SECURA_API="https://scans.example.com/api/v1"
112
+ secura scan owner/repo
113
+ ```
114
+
115
+ The default is `http://localhost:8000/api/v1`. The scan endpoints are unauthenticated;
116
+ `--token` / `SECURA_TOKEN` is sent as a bearer header only if provided, so it keeps
117
+ working if auth is added later.
118
+
119
+ ## CI example
120
+
121
+ ```yaml
122
+ # GitHub Actions — fail the build on a confirmed high+ finding
123
+ - name: SAST
124
+ run: npx @authtrack/secura scan . --fast --fail-on high --json sast.json
125
+ env:
126
+ SECURA_API: ${{ secrets.SECURA_API }}
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Publishing (maintainers)
132
+
133
+ This package publishes from the `cli/` directory of the AuthTrack repo. It is
134
+ plain ESM with no build step, so publishing is just:
135
+
136
+ ```bash
137
+ cd cli
138
+ npm login # once per machine
139
+ npm version patch # 0.1.0 -> 0.1.1 (bumps package.json + git tag)
140
+ npm publish --access public # scoped packages need --access public on first publish
141
+ ```
142
+
143
+ Notes:
144
+
145
+ - **Scope.** The name `@authtrack/secura` requires the `@authtrack` org to exist
146
+ on npm and your account to be a member. Create it at
147
+ <https://www.npmjs.com/org/create>, or rename the package to an unscoped name
148
+ you own (e.g. `authtrack-secura`) in `package.json` — the `bin` stays `secura`
149
+ either way, so `secura scan …` is unchanged for users.
150
+ - **What ships.** Only `bin/`, `src/` and `README.md` (the `files` allowlist).
151
+ Verify with `npm pack --dry-run` before publishing.
152
+ - **Smoke test the tarball.** `npm pack` then
153
+ `npm i -g ./authtrack-secura-<version>.tgz` and run `secura --help`.
154
+
155
+ ## License
156
+
157
+ MIT
package/bin/secura.mjs ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * secura — AuthTrack static analysis CLI (npm distribution).
4
+ *
5
+ * A dependency-free Node client for the backend's static-analysis SSE endpoints.
6
+ * The heavy scanning runs on the backend; this renders the streamed run and the
7
+ * final report in your terminal. See ../README.md.
8
+ */
9
+
10
+ import { parseArgs } from "../src/args.mjs";
11
+ import { runScan } from "../src/commands/scan.mjs";
12
+ import { printHelp, printScanHelp } from "../src/help.mjs";
13
+ import { red, showCursor } from "../src/ansi.mjs";
14
+
15
+ const VERSION = "0.1.0";
16
+
17
+ // Canonical long name → { type, alias }. Mirrors the Python `scan` command's
18
+ // flags, plus the API-client-only --api / --token / --upload.
19
+ const SCAN_SPEC = {
20
+ fast: { type: "boolean", alias: "f" },
21
+ language: { type: "string", alias: "l" },
22
+ "max-triage": { type: "number", alias: "t" },
23
+ "fail-on": { type: "string" },
24
+ json: { type: "string", alias: "o" },
25
+ "max-findings": { type: "number" },
26
+ "show-fixes": { type: "boolean" },
27
+ quiet: { type: "boolean", alias: "q" },
28
+ api: { type: "string" },
29
+ token: { type: "string" },
30
+ upload: { type: "boolean" },
31
+ help: { type: "boolean", alias: "h" },
32
+ };
33
+
34
+ async function main() {
35
+ const argv = process.argv.slice(2);
36
+ const cmd = argv[0];
37
+
38
+ if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
39
+ printHelp();
40
+ return 0;
41
+ }
42
+ if (cmd === "version" || cmd === "--version" || cmd === "-v") {
43
+ process.stdout.write(`secura ${VERSION} — AuthTrack static analysis CLI (npm)\n`);
44
+ return 0;
45
+ }
46
+ if (cmd === "scan") {
47
+ const { values, positionals, errors } = parseArgs(argv.slice(1), SCAN_SPEC);
48
+ if (values.help) {
49
+ printScanHelp();
50
+ return 0;
51
+ }
52
+ if (errors.length) {
53
+ for (const e of errors) process.stderr.write(`${red("error:")} ${e}\n`);
54
+ process.stderr.write("Run `secura scan --help` for usage.\n");
55
+ return 2;
56
+ }
57
+ return runScan(values, positionals);
58
+ }
59
+
60
+ process.stderr.write(`${red(`Unknown command '${cmd}'.`)} Try \`secura --help\`.\n`);
61
+ return 2;
62
+ }
63
+
64
+ main()
65
+ .then((code) => process.exit(code ?? 0))
66
+ .catch((err) => {
67
+ showCursor(); // never leave the terminal cursor hidden after a crash
68
+ process.stderr.write(`${red("Fatal:")} ${err && err.stack ? err.stack : String(err)}\n`);
69
+ process.exit(2);
70
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@authtrack/secura",
3
+ "version": "0.1.0",
4
+ "description": "AuthTrack static analysis (SAST) from the command line — streams a five-scanner scan (Semgrep, Bearer, OSV-Scanner, Gitleaks, CodeQL) from the AuthTrack backend and renders it live in your terminal.",
5
+ "type": "module",
6
+ "bin": {
7
+ "secura": "bin/secura.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "keywords": [
18
+ "sast",
19
+ "static-analysis",
20
+ "security",
21
+ "semgrep",
22
+ "codeql",
23
+ "gitleaks",
24
+ "osv-scanner",
25
+ "bearer",
26
+ "cli",
27
+ "vulnerability",
28
+ "authtrack"
29
+ ],
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/ashleyalmeida07/Authtrack-Major-Project.git",
33
+ "directory": "cli"
34
+ },
35
+ "homepage": "https://github.com/ashleyalmeida07/Authtrack-Major-Project#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/ashleyalmeida07/Authtrack-Major-Project/issues"
38
+ },
39
+ "license": "MIT",
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "scripts": {
44
+ "secura": "node bin/secura.mjs"
45
+ }
46
+ }
package/src/ansi.mjs ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Zero-dependency ANSI styling.
3
+ *
4
+ * Colour is enabled only when stdout is a TTY and `NO_COLOR` is unset (honouring
5
+ * https://no-color.org), or forced on with `FORCE_COLOR`. When disabled every
6
+ * helper is the identity function, so the same render code produces clean text
7
+ * for pipes, CI logs and `--json` redirects.
8
+ */
9
+
10
+ const enabled =
11
+ !process.env.NO_COLOR &&
12
+ (Boolean(process.env.FORCE_COLOR) || Boolean(process.stdout.isTTY));
13
+
14
+ function wrap(open, close) {
15
+ const prefix = `\x1b[${open}m`;
16
+ const suffix = `\x1b[${close}m`;
17
+ return (s) => (enabled ? prefix + String(s) + suffix : String(s));
18
+ }
19
+
20
+ export const colorEnabled = enabled;
21
+
22
+ export const bold = wrap(1, 22);
23
+ export const dim = wrap(2, 22);
24
+
25
+ export const red = wrap(31, 39);
26
+ export const green = wrap(32, 39);
27
+ export const yellow = wrap(33, 39);
28
+ export const blue = wrap(34, 39);
29
+ export const magenta = wrap(35, 39);
30
+ export const cyan = wrap(36, 39);
31
+ export const gray = wrap(90, 39); // bright_black
32
+ export const brightRed = wrap(91, 39);
33
+ export const white = wrap(37, 39);
34
+
35
+ // Mirrors SEVERITY_COLOR in backend/app/cli/main.py.
36
+ const SEVERITY_FN = {
37
+ critical: brightRed,
38
+ high: red,
39
+ medium: yellow,
40
+ low: cyan,
41
+ info: gray,
42
+ };
43
+
44
+ /** Uppercase, severity-coloured label — e.g. a red "HIGH". */
45
+ export function severityText(name) {
46
+ const key = String(name || "info").toLowerCase();
47
+ const fn = SEVERITY_FN[key] || white;
48
+ return fn(key.toUpperCase());
49
+ }
50
+
51
+ // ── Cursor control (live view) ──
52
+
53
+ export const hideCursor = () => {
54
+ if (enabled) process.stdout.write("\x1b[?25l");
55
+ };
56
+ export const showCursor = () => {
57
+ if (enabled) process.stdout.write("\x1b[?25h");
58
+ };
59
+
60
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
61
+
62
+ /** Visible length of a string, ignoring ANSI escape sequences. */
63
+ export function visibleLength(s) {
64
+ return s.replace(ANSI_RE, "").length;
65
+ }
66
+
67
+ /**
68
+ * Truncate to `width` visible columns, preserving ANSI codes and appending an
69
+ * ellipsis when clipped. Keeps the live view from wrapping in a narrow terminal
70
+ * (which would break the cursor-up redraw).
71
+ */
72
+ export function clip(s, width) {
73
+ if (width <= 0 || visibleLength(s) <= width) return s;
74
+ let out = "";
75
+ let vis = 0;
76
+ for (let i = 0; i < s.length; ) {
77
+ const rest = s.slice(i);
78
+ const m = rest.match(/^\x1b\[[0-9;]*m/);
79
+ if (m) {
80
+ out += m[0];
81
+ i += m[0].length;
82
+ continue;
83
+ }
84
+ if (vis >= width - 1) {
85
+ out += "…";
86
+ break;
87
+ }
88
+ out += s[i];
89
+ vis += 1;
90
+ i += 1;
91
+ }
92
+ return out + (enabled ? "\x1b[0m" : "");
93
+ }
package/src/args.mjs ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * A tiny, dependency-free argv parser.
3
+ *
4
+ * Handles the shapes the `secura` commands need and nothing more:
5
+ * --flag boolean true
6
+ * --key value string / number (next token is the value)
7
+ * --key=value string / number (inline)
8
+ * -f short boolean (clusterable: -fq)
9
+ * -l value | -lvalue | -l=value short with a value
10
+ * -- everything after is positional
11
+ *
12
+ * `spec` maps a canonical long name → { type: "boolean"|"string"|"number", alias?: "x" }.
13
+ * Returns { values, positionals, errors }. Unknown options and malformed
14
+ * numbers are collected in `errors` rather than thrown, so the caller decides
15
+ * whether to abort or show usage.
16
+ */
17
+ export function parseArgs(argv, spec) {
18
+ const aliasToName = {};
19
+ for (const [name, opt] of Object.entries(spec)) {
20
+ if (opt.alias) aliasToName[opt.alias] = name;
21
+ }
22
+
23
+ const values = {};
24
+ const positionals = [];
25
+ const errors = [];
26
+
27
+ const coerce = (opt, raw, label) => {
28
+ if (opt.type === "number") {
29
+ const n = Number(raw);
30
+ if (Number.isNaN(n)) {
31
+ errors.push(`${label} expects a number, got '${raw}'`);
32
+ return undefined;
33
+ }
34
+ return n;
35
+ }
36
+ return raw;
37
+ };
38
+
39
+ for (let i = 0; i < argv.length; i += 1) {
40
+ const arg = argv[i];
41
+
42
+ if (arg === "--") {
43
+ positionals.push(...argv.slice(i + 1));
44
+ break;
45
+ }
46
+
47
+ // Long option: --name, --name=value
48
+ if (arg.startsWith("--")) {
49
+ let key = arg.slice(2);
50
+ let inline;
51
+ const eq = key.indexOf("=");
52
+ if (eq !== -1) {
53
+ inline = key.slice(eq + 1);
54
+ key = key.slice(0, eq);
55
+ }
56
+ const name = spec[key] ? key : aliasToName[key];
57
+ if (!name) {
58
+ errors.push(`unknown option --${key}`);
59
+ continue;
60
+ }
61
+ const opt = spec[name];
62
+ if (opt.type === "boolean") {
63
+ values[name] = inline === undefined ? true : /^(1|true|yes|on)$/i.test(inline);
64
+ } else {
65
+ let raw = inline;
66
+ if (raw === undefined) {
67
+ raw = argv[++i];
68
+ if (raw === undefined) {
69
+ errors.push(`--${key} needs a value`);
70
+ continue;
71
+ }
72
+ }
73
+ const v = coerce(opt, raw, `--${key}`);
74
+ if (v !== undefined) values[name] = v;
75
+ }
76
+ continue;
77
+ }
78
+
79
+ // Short option(s): -f, -fq, -l value, -lvalue, -l=value
80
+ if (arg.length > 1 && arg[0] === "-") {
81
+ const chars = arg.slice(1);
82
+ for (let c = 0; c < chars.length; c += 1) {
83
+ const ch = chars[c];
84
+ const name = aliasToName[ch];
85
+ if (!name) {
86
+ errors.push(`unknown option -${ch}`);
87
+ break;
88
+ }
89
+ const opt = spec[name];
90
+ if (opt.type === "boolean") {
91
+ values[name] = true;
92
+ continue; // allow clustering, e.g. -fq
93
+ }
94
+ // value option consumes the rest of this token, or the next argv entry
95
+ let raw = chars.slice(c + 1);
96
+ if (raw.startsWith("=")) raw = raw.slice(1);
97
+ if (!raw) {
98
+ raw = argv[++i];
99
+ if (raw === undefined) {
100
+ errors.push(`-${ch} needs a value`);
101
+ break;
102
+ }
103
+ }
104
+ const v = coerce(opt, raw, `-${ch}`);
105
+ if (v !== undefined) values[name] = v;
106
+ break; // rest of the token was the value
107
+ }
108
+ continue;
109
+ }
110
+
111
+ positionals.push(arg);
112
+ }
113
+
114
+ return { values, positionals, errors };
115
+ }
package/src/client.mjs ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Backend client — POSTs to the static-analysis stream endpoints and yields the
3
+ * parsed SSE events.
4
+ *
5
+ * The wire format matches backend/app/api/scan.py: newline-delimited frames of
6
+ * `data: {json}\n\n`, each carrying one of:
7
+ * { event: "start", message }
8
+ * { event: "node_update", node, state }
9
+ * { event: "complete", message }
10
+ * { event: "error", message }
11
+ *
12
+ * This mirrors the reader loop in frontend/src/lib/api.ts (split on "\n\n",
13
+ * parse `data:` lines) so the CLI and the browser consume an identical stream.
14
+ * Uses Node's global fetch + Web Streams (Node >= 18) — no dependencies.
15
+ */
16
+
17
+ function authHeaders(token, extra = {}) {
18
+ const h = { ...extra };
19
+ if (token) h.Authorization = `Bearer ${token}`;
20
+ return h;
21
+ }
22
+
23
+ async function openStream(url, token, headers, body) {
24
+ let res;
25
+ try {
26
+ res = await fetch(url, { method: "POST", headers: authHeaders(token, headers), body });
27
+ } catch (err) {
28
+ throw new Error(
29
+ `Could not reach the backend at ${url}\n` +
30
+ ` ${err.message}\n` +
31
+ ` Is it running? Point elsewhere with --api <url> or the SECURA_API env var.`
32
+ );
33
+ }
34
+ if (!res.ok) {
35
+ let detail = "";
36
+ try {
37
+ detail = (await res.text()).slice(0, 500).trim();
38
+ } catch {
39
+ /* ignore */
40
+ }
41
+ throw new Error(`Backend returned ${res.status} ${res.statusText}${detail ? `\n ${detail}` : ""}`);
42
+ }
43
+ if (!res.body) throw new Error("Backend sent an empty response body.");
44
+ return res;
45
+ }
46
+
47
+ async function* readSse(res) {
48
+ const decoder = new TextDecoder();
49
+ const reader = res.body.getReader();
50
+ let buffer = "";
51
+
52
+ const emit = function* (frame) {
53
+ // A frame may hold several lines; the backend uses a single `data:` line.
54
+ for (const rawLine of frame.split("\n")) {
55
+ if (!rawLine.startsWith("data:")) continue;
56
+ const jsonStr = rawLine.slice(rawLine.indexOf(":") + 1).trim();
57
+ if (!jsonStr) continue;
58
+ try {
59
+ yield JSON.parse(jsonStr);
60
+ } catch {
61
+ // Malformed frame — skip it (the browser client does the same).
62
+ }
63
+ }
64
+ };
65
+
66
+ while (true) {
67
+ const { done, value } = await reader.read();
68
+ if (done) break;
69
+ buffer += decoder.decode(value, { stream: true });
70
+ const frames = buffer.split("\n\n");
71
+ buffer = frames.pop() || ""; // keep the trailing partial frame
72
+ for (const frame of frames) yield* emit(frame);
73
+ }
74
+ // Flush any final frame that arrived without a trailing blank line.
75
+ const tail = buffer.trim();
76
+ if (tail) yield* emit(tail);
77
+ }
78
+
79
+ /** Stream a scan of a repo URL or a co-located local path (JSON body). */
80
+ export async function* streamStatic(api, token, body) {
81
+ const res = await openStream(
82
+ `${api}/scan/stream/static`,
83
+ token,
84
+ { "Content-Type": "application/json" },
85
+ JSON.stringify(body)
86
+ );
87
+ yield* readSse(res);
88
+ }
89
+
90
+ /** Stream a scan of uploaded local code (multipart tar.gz + form fields). */
91
+ export async function* streamStaticUpload(api, token, form) {
92
+ // Let fetch set the multipart Content-Type (with boundary) from the FormData.
93
+ const res = await openStream(`${api}/scan/stream/static/upload`, token, {}, form);
94
+ yield* readSse(res);
95
+ }