@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 +157 -0
- package/bin/secura.mjs +70 -0
- package/package.json +46 -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/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
|
+
}
|