@secureport/cli 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Jordan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # @secureport/cli
2
+
3
+ Turn a scanner's output into a client-ready security report.
4
+
5
+ **MIT licensed. Runs entirely offline.** There is no account, nothing is
6
+ uploaded, and nothing leaves the machine.
7
+
8
+ ```bash
9
+ npx @secureport/cli report pen scan.jsonl --url https://app.example.com > report.md
10
+ ```
11
+
12
+ Reads Nuclei, ZAP, Burp, Nessus and a generic JSON format, and works out which
13
+ one it is looking at. Renders five reports — Penetration Test, Vulnerability
14
+ Assessment, Executive Summary, Attestation Letter and Retest — as Markdown,
15
+ HTML or JSON.
16
+
17
+ ## Two runs, and it remembers
18
+
19
+ The tracked thing is the **issue**, not the finding, so a second run can say
20
+ what got fixed and what came back.
21
+
22
+ ```bash
23
+ npx @secureport/cli import run1.jsonl --url https://app.example.com --out run1.json
24
+ npx @secureport/cli report retest run2.jsonl --previous run1.json
25
+ ```
26
+
27
+ ```
28
+ Of the 4 issues carried into this retest, 1 is confirmed fixed, 3 are still
29
+ present, 0 returned, and 0 could not be retested.
30
+ ```
31
+
32
+ The second command needs no `--url`: the baseline carries the target, and a
33
+ baseline for a different target is refused rather than compared.
34
+
35
+ **"Could not be retested" is not "fixed".** A run that did not cover where an
36
+ issue was found proves nothing about it, so it gets its own verdict and is left
37
+ out of the percentage rather than counted either way. Pass `--scope` to say what
38
+ a run actually covered.
39
+
40
+ ## Commands
41
+
42
+ | | |
43
+ | --------------------------------- | ----------------------------------------------------------- |
44
+ | `secureport report <kind> <file>` | Render a report. `pen`, `vap`, `exec`, `attest` or `retest` |
45
+ | `secureport import <file>` | Reconcile findings into a snapshot for a later `--previous` |
46
+ | `secureport validate <file>` | Say what is in a file, and whether it parses |
47
+
48
+ `secureport --help` lists every option. Exit codes are `0` for success, `1` for
49
+ a bad command line and `2` for a bad input file.
50
+
51
+ ## What it does not claim
52
+
53
+ Reports state how their findings were produced and what that does not
54
+ establish. Automated results are described as automated, a partial scope names
55
+ itself, and nothing here certifies compliance with any standard. That is
56
+ derived from the run rather than written into a template, so it cannot drift
57
+ out of date.
58
+
59
+ The domain model, the fingerprint and the reconciliation are
60
+ [`@secureport/core`](https://www.npmjs.com/package/@secureport/core), which this
61
+ wraps. Both are MIT.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { run } from './run.js';
5
+ /**
6
+ * The executable.
7
+ *
8
+ * **The only file in this package that touches the outside world**, and the
9
+ * only one permitted to import `node:fs`. Everything the CLI actually does is
10
+ * in `run.ts`, behind an {@link Io} it is handed — so the whole command
11
+ * surface is testable in-process without a temporary directory or a spawned
12
+ * process.
13
+ */
14
+ const io = {
15
+ readFile: (path) => readFileSync(path, 'utf8'),
16
+ writeFile: (path, content) => {
17
+ writeFileSync(path, content, 'utf8');
18
+ },
19
+ out: (text) => {
20
+ process.stdout.write(text);
21
+ },
22
+ err: (text) => {
23
+ process.stderr.write(text);
24
+ },
25
+ now: () => new Date(),
26
+ };
27
+ const { version } = createRequire(import.meta.url)('../package.json');
28
+ process.exitCode = run(process.argv.slice(2), io, version);
29
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACtD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,GAAG,EAAW,MAAM,UAAU,CAAC;AAExC;;;;;;;;GAQG;AACH,MAAM,EAAE,GAAO;IACb,QAAQ,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC;IAC9C,SAAS,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE;QAC3B,aAAa,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;IACvC,CAAC;IACD,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE;QACZ,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC;IACD,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE;QACZ,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC;IACD,GAAG,EAAE,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE;CACtB,CAAC;AAEF,MAAM,EAAE,OAAO,EAAE,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,iBAAiB,CAAwB,CAAC;AAE7F,OAAO,CAAC,QAAQ,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC"}
@@ -0,0 +1,21 @@
1
+ /** The scanner formats the CLI can read. */
2
+ export type Engine = 'nuclei' | 'zap' | 'burp' | 'nessus' | 'generic';
3
+ /** Every engine name, for error messages and `--engine` validation. */
4
+ export declare const ENGINES: readonly Engine[];
5
+ /**
6
+ * Works out which scanner produced a file, from its content.
7
+ *
8
+ * **Detection exists so the five-minute path has one fewer thing to know.**
9
+ * Somebody with a Nuclei file should not have to discover a `--engine` flag
10
+ * before they can render anything; `--engine` is there to override this when
11
+ * it guesses wrong, not to be mandatory.
12
+ *
13
+ * Every branch keys on something structural — a root element name, a
14
+ * required top-level field — rather than on a filename or an extension, both
15
+ * of which the user controls and neither of which the scanner guarantees.
16
+ *
17
+ * @param text - The file's content.
18
+ * @returns The engine, or `undefined` when nothing matches confidently.
19
+ */
20
+ export declare function detectEngine(text: string): Engine | undefined;
21
+ //# sourceMappingURL=detect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect.d.ts","sourceRoot":"","sources":["../src/detect.ts"],"names":[],"mappings":"AAAA,4CAA4C;AAC5C,MAAM,MAAM,MAAM,GAAG,QAAQ,GAAG,KAAK,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC;AAEtE,uEAAuE;AACvE,eAAO,MAAM,OAAO,EAAE,SAAS,MAAM,EAAmD,CAAC;AAEzF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAiC7D"}
package/dist/detect.js ADDED
@@ -0,0 +1,65 @@
1
+ /** Every engine name, for error messages and `--engine` validation. */
2
+ export const ENGINES = ['nuclei', 'zap', 'burp', 'nessus', 'generic'];
3
+ /**
4
+ * Works out which scanner produced a file, from its content.
5
+ *
6
+ * **Detection exists so the five-minute path has one fewer thing to know.**
7
+ * Somebody with a Nuclei file should not have to discover a `--engine` flag
8
+ * before they can render anything; `--engine` is there to override this when
9
+ * it guesses wrong, not to be mandatory.
10
+ *
11
+ * Every branch keys on something structural — a root element name, a
12
+ * required top-level field — rather than on a filename or an extension, both
13
+ * of which the user controls and neither of which the scanner guarantees.
14
+ *
15
+ * @param text - The file's content.
16
+ * @returns The engine, or `undefined` when nothing matches confidently.
17
+ */
18
+ export function detectEngine(text) {
19
+ const trimmed = text.trimStart();
20
+ if (trimmed === '')
21
+ return undefined;
22
+ if (trimmed.startsWith('<')) {
23
+ // Nessus names itself in its root element. Burp's root is `<issues>`, and
24
+ // the `burpVersion` attribute is what separates it from anything else
25
+ // that happens to call its root that.
26
+ if (/<NessusClientData_v2[\s>]/u.test(trimmed))
27
+ return 'nessus';
28
+ if (/<issues[\s>]/u.test(trimmed))
29
+ return 'burp';
30
+ return undefined;
31
+ }
32
+ // A generic document declares its own version, which is the whole reason
33
+ // that field exists — so check it before anything that guesses.
34
+ const whole = parse(trimmed);
35
+ if (whole !== undefined && typeof whole === 'object' && !Array.isArray(whole)) {
36
+ const record = whole;
37
+ if ('version' in record && Array.isArray(record['findings']))
38
+ return 'generic';
39
+ // ZAP writes one object with a `site` array; the alerts hang off it.
40
+ if (Array.isArray(record['site']))
41
+ return 'zap';
42
+ return undefined;
43
+ }
44
+ // Nuclei is JSONL: the whole file is not one document, but each line is.
45
+ const first = trimmed.split('\n').find((line) => line.trim() !== '');
46
+ if (first === undefined)
47
+ return undefined;
48
+ const line = parse(first);
49
+ if (line !== undefined && typeof line === 'object' && !Array.isArray(line)) {
50
+ const record = line;
51
+ if ('template-id' in record || 'matched-at' in record)
52
+ return 'nuclei';
53
+ }
54
+ return undefined;
55
+ }
56
+ /** `JSON.parse` that answers "no" instead of throwing. */
57
+ function parse(text) {
58
+ try {
59
+ return JSON.parse(text);
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ //# sourceMappingURL=detect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect.js","sourceRoot":"","sources":["../src/detect.ts"],"names":[],"mappings":"AAGA,uEAAuE;AACvE,MAAM,CAAC,MAAM,OAAO,GAAsB,CAAC,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,CAAC,CAAC;AAEzF;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;IACjC,IAAI,OAAO,KAAK,EAAE;QAAE,OAAO,SAAS,CAAC;IAErC,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QAC5B,0EAA0E;QAC1E,sEAAsE;QACtE,sCAAsC;QACtC,IAAI,4BAA4B,CAAC,IAAI,CAAC,OAAO,CAAC;YAAE,OAAO,QAAQ,CAAC;QAChE,IAAI,eAAe,CAAC,IAAI,CAAC,OAAO,CAAC;YAAE,OAAO,MAAM,CAAC;QACjD,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,yEAAyE;IACzE,gEAAgE;IAChE,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC;IAC7B,IAAI,KAAK,KAAK,SAAS,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QAC9E,MAAM,MAAM,GAAG,KAAgC,CAAC;QAChD,IAAI,SAAS,IAAI,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;YAAE,OAAO,SAAS,CAAC;QAC/E,qEAAqE;QACrE,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;YAAE,OAAO,KAAK,CAAC;QAChD,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,yEAAyE;IACzE,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACrE,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAC1C,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC;IAC1B,IAAI,IAAI,KAAK,SAAS,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3E,MAAM,MAAM,GAAG,IAA+B,CAAC;QAC/C,IAAI,aAAa,IAAI,MAAM,IAAI,YAAY,IAAI,MAAM;YAAE,OAAO,QAAQ,CAAC;IACzE,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,0DAA0D;AAC1D,SAAS,KAAK,CAAC,IAAY;IACzB,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC"}
package/dist/run.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Everything the CLI touches outside itself.
3
+ *
4
+ * Injected rather than reached for, so the whole command surface can be
5
+ * exercised in-process by a test that hands it strings and reads strings
6
+ * back. `cli.ts` is the only file that builds a real one, and it is the only
7
+ * file in this package permitted to import `node:fs`.
8
+ */
9
+ export interface Io {
10
+ /** Reads a file as UTF-8. Throws if it cannot. */
11
+ readFile(path: string): string;
12
+ /** Writes a file as UTF-8. */
13
+ writeFile(path: string, content: string): void;
14
+ /** Writes to standard output. The report goes here. */
15
+ out(text: string): void;
16
+ /** Writes to standard error. Diagnostics go here, never the report. */
17
+ err(text: string): void;
18
+ /** The wall clock. An argument so output can be made reproducible. */
19
+ now(): Date;
20
+ }
21
+ /**
22
+ * Exit codes, which are a contract for anything scripting this.
23
+ *
24
+ * - `0` — it worked.
25
+ * - `1` — the command line was wrong: unknown flag, missing argument, a
26
+ * `--format` that does not exist. Nothing was read.
27
+ * - `2` — the input was wrong: a file that does not parse, an engine that
28
+ * could not be detected, a baseline against a different target.
29
+ */
30
+ export declare const EXIT: Readonly<{
31
+ ok: 0;
32
+ usage: 1;
33
+ input: 2;
34
+ }>;
35
+ /**
36
+ * Runs one command.
37
+ *
38
+ * @param argv - Arguments after the executable and script, as `process.argv.slice(2)`.
39
+ * @param io - Where to read, write and read the clock.
40
+ * @param version - The package version, for `--version`.
41
+ * @returns The process exit code.
42
+ */
43
+ export declare function run(argv: readonly string[], io: Io, version: string): number;
44
+ //# sourceMappingURL=run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AAqBA;;;;;;;GAOG;AACH,MAAM,WAAW,EAAE;IACjB,kDAAkD;IAClD,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAE/B,8BAA8B;IAC9B,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAE/C,uDAAuD;IACvD,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAExB,uEAAuE;IACvE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAExB,sEAAsE;IACtE,GAAG,IAAI,IAAI,CAAC;CACb;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,IAAI;;;;EAA+C,CAAC;AA2DjE;;;;;;;GAOG;AACH,wBAAgB,GAAG,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAa5E"}
package/dist/run.js ADDED
@@ -0,0 +1,390 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { parseArgs } from 'node:util';
3
+ import { buildSnapshot, importBurp, importGeneric, importNessus, importNuclei, importZap, parseSnapshot, renderHtml, renderJson, renderMarkdown, } from '@secureport/core';
4
+ import { detectEngine, ENGINES } from './detect.js';
5
+ /**
6
+ * Exit codes, which are a contract for anything scripting this.
7
+ *
8
+ * - `0` — it worked.
9
+ * - `1` — the command line was wrong: unknown flag, missing argument, a
10
+ * `--format` that does not exist. Nothing was read.
11
+ * - `2` — the input was wrong: a file that does not parse, an engine that
12
+ * could not be detected, a baseline against a different target.
13
+ */
14
+ export const EXIT = Object.freeze({ ok: 0, usage: 1, input: 2 });
15
+ const REPORT_KINDS = ['pen', 'vap', 'exec', 'attest', 'retest'];
16
+ const FORMATS = ['md', 'html', 'json'];
17
+ const RUN_KINDS = ['scan', 'upload', 'manual'];
18
+ const HELP = `secureport — turn a scanner's output into a client-ready report.
19
+
20
+ Everything here runs offline against local files. There is no account, nothing
21
+ is uploaded, and nothing leaves the machine.
22
+
23
+ Usage
24
+ secureport report <kind> <file> Render a report to stdout
25
+ secureport import <file> Reconcile findings into a snapshot
26
+ secureport validate <file> Check a file parses, and say what is in it
27
+
28
+ Report kinds
29
+ pen Penetration Test — every issue in full, with its evidence
30
+ vap Vulnerability Assessment — the same issues, tabulated
31
+ exec Executive Summary — where things stand, no technical detail
32
+ attest Attestation Letter — a formal statement, with its limits stated
33
+ retest Retest — what a second run fixed, and what came back
34
+
35
+ Options
36
+ --engine <name> nuclei, zap, burp, nessus or generic. Detected by default
37
+ --previous <file> A snapshot from an earlier run, to compare against
38
+ --format <fmt> md, html or json. Default: md
39
+ --out <file> Write here instead of stdout
40
+ --snapshot-out <file> Also write the snapshot, for the next run's --previous
41
+ --url <url> What was tested. Part of every fingerprint
42
+ --target <name> Its name on the report cover
43
+ --scope <glob> What the run covered. Repeatable. Default: everything
44
+ --run-kind <kind> scan, upload or manual. Default: upload
45
+ --title <text> Override the report title
46
+ --prepared-by <text> Who prepared it
47
+ --prepared-for <text> Who it is for
48
+ --now <iso8601> Freeze the clock, for byte-identical output
49
+ -h, --help This
50
+ -v, --version Print the version
51
+
52
+ Two runs, no account
53
+ secureport import run1.jsonl --url https://app.example.com --out run1.json
54
+ secureport report retest run2.jsonl --previous run1.json
55
+
56
+ The second command needs no --url: the baseline carries the target, and a
57
+ baseline for a different target is refused rather than compared.
58
+ `;
59
+ /** A command that failed in a way the user can act on. */
60
+ class CliError extends Error {
61
+ code;
62
+ constructor(message, code) {
63
+ super(message);
64
+ this.code = code;
65
+ this.name = 'CliError';
66
+ }
67
+ }
68
+ /**
69
+ * Runs one command.
70
+ *
71
+ * @param argv - Arguments after the executable and script, as `process.argv.slice(2)`.
72
+ * @param io - Where to read, write and read the clock.
73
+ * @param version - The package version, for `--version`.
74
+ * @returns The process exit code.
75
+ */
76
+ export function run(argv, io, version) {
77
+ try {
78
+ return dispatch(argv, io, version);
79
+ }
80
+ catch (error) {
81
+ if (error instanceof CliError) {
82
+ io.err(`secureport: ${error.message}\n`);
83
+ return error.code;
84
+ }
85
+ // An unexpected failure is still the user's problem to see, and a stack
86
+ // trace is not a message. The message is, and importers write good ones.
87
+ io.err(`secureport: ${error instanceof Error ? error.message : String(error)}\n`);
88
+ return EXIT.input;
89
+ }
90
+ }
91
+ function dispatch(argv, io, version) {
92
+ let values;
93
+ let positionals;
94
+ try {
95
+ const parsed = parseArgs({
96
+ args: [...argv],
97
+ allowPositionals: true,
98
+ options: {
99
+ engine: { type: 'string' },
100
+ previous: { type: 'string' },
101
+ format: { type: 'string' },
102
+ out: { type: 'string' },
103
+ 'snapshot-out': { type: 'string' },
104
+ url: { type: 'string' },
105
+ target: { type: 'string' },
106
+ scope: { type: 'string', multiple: true },
107
+ 'run-kind': { type: 'string' },
108
+ title: { type: 'string' },
109
+ 'prepared-by': { type: 'string' },
110
+ 'prepared-for': { type: 'string' },
111
+ now: { type: 'string' },
112
+ help: { type: 'boolean', short: 'h' },
113
+ version: { type: 'boolean', short: 'v' },
114
+ },
115
+ });
116
+ values = parsed.values;
117
+ positionals = parsed.positionals;
118
+ }
119
+ catch (error) {
120
+ throw new CliError(`${error instanceof Error ? error.message : String(error)}\nRun \`secureport --help\`.`, EXIT.usage);
121
+ }
122
+ if (values['version'] === true) {
123
+ io.out(`${version}\n`);
124
+ return EXIT.ok;
125
+ }
126
+ if (values['help'] === true || positionals.length === 0) {
127
+ io.out(HELP);
128
+ return EXIT.ok;
129
+ }
130
+ const command = positionals[0];
131
+ switch (command) {
132
+ case 'report':
133
+ return report(values, positionals.slice(1), io);
134
+ case 'import':
135
+ return importCommand(values, positionals.slice(1), io);
136
+ case 'validate':
137
+ return validate(values, positionals.slice(1), io);
138
+ default:
139
+ throw new CliError(`unknown command \`${command}\`. Expected report, import or validate.`, EXIT.usage);
140
+ }
141
+ }
142
+ /** A string option, or undefined. `parseArgs` gives us `string | boolean`. */
143
+ function str(values, name) {
144
+ const value = values[name];
145
+ return typeof value === 'string' ? value : undefined;
146
+ }
147
+ /** One `--option value` that must be one of a known set. */
148
+ function oneOf(values, name, allowed) {
149
+ const value = str(values, name);
150
+ if (value === undefined)
151
+ return undefined;
152
+ if (!allowed.includes(value)) {
153
+ throw new CliError(`--${name} must be one of ${allowed.join(', ')} (got \`${value}\`)`, EXIT.usage);
154
+ }
155
+ return value;
156
+ }
157
+ /** The one positional a command needs, or a usage error naming it. */
158
+ function onlyPositional(rest, what) {
159
+ const [first, ...extra] = rest;
160
+ if (first === undefined)
161
+ throw new CliError(`expected a ${what}. Run \`secureport --help\`.`, EXIT.usage);
162
+ if (extra.length > 0)
163
+ throw new CliError(`unexpected argument \`${extra[0]}\``, EXIT.usage);
164
+ return first;
165
+ }
166
+ /** Reads a file, turning a missing one into a message rather than a stack. */
167
+ function read(io, path) {
168
+ try {
169
+ return io.readFile(path);
170
+ }
171
+ catch {
172
+ throw new CliError(`cannot read \`${path}\``, EXIT.input);
173
+ }
174
+ }
175
+ /**
176
+ * A stable id derived from content.
177
+ *
178
+ * **Ids have to be deterministic here, and they have to not collide across
179
+ * runs.** Deterministic, because a report re-rendered from the same input must
180
+ * come out byte-identical or it cannot be compared with the copy somebody was
181
+ * sent. Non-colliding, because a second run's new issues are appended to a
182
+ * baseline's existing ones, and a counter restarting at 1 would reuse ids the
183
+ * baseline already holds.
184
+ *
185
+ * Hashing the thing being identified gives both at once.
186
+ */
187
+ function idFrom(prefix, material) {
188
+ return `${prefix}_${createHash('sha256').update(material).digest('hex').slice(0, 12)}`;
189
+ }
190
+ /** Parses a scanner file and reconciles it into a snapshot. */
191
+ function load(values, file, io) {
192
+ const text = read(io, file);
193
+ const engine = oneOf(values, 'engine', ENGINES) ?? detectEngine(text);
194
+ if (engine === undefined) {
195
+ throw new CliError(`cannot tell which scanner produced \`${file}\`. Pass --engine (${ENGINES.join(', ')}).`, EXIT.input);
196
+ }
197
+ const previousPath = str(values, 'previous');
198
+ const previous = previousPath === undefined ? undefined : loadSnapshot(io, previousPath);
199
+ // The baseline carries the target, so a retest needs no --url. An explicit
200
+ // one that disagrees is refused rather than honoured: the target id is in
201
+ // every fingerprint, so comparing across two of them would report every
202
+ // issue as resolved and every issue as new, which is worse than an error.
203
+ const url = str(values, 'url') ?? previous?.target.url;
204
+ const targetId = url === undefined ? 'tgt_local' : idFrom('tgt', url);
205
+ if (previous !== undefined && targetId !== previous.target.id) {
206
+ throw new CliError(`--previous is a snapshot of ${previous.target.url}, and --url says ${url}. ` +
207
+ 'A target id is part of every fingerprint, so comparing across two of them would ' +
208
+ 'report everything as both resolved and new. Drop --url to use the baseline, or ' +
209
+ 'drop --previous to start fresh.', EXIT.input);
210
+ }
211
+ const now = parseNow(values, io);
212
+ const runId = idFrom('run', text);
213
+ const paths = Array.isArray(values['scope']) ? values['scope'] : [];
214
+ if (paths.length === 0 && previous !== undefined) {
215
+ io.err('secureport: no --scope given, so this run is assumed to have covered everything, and ' +
216
+ 'anything the baseline had that this file does not will resolve. Pass --scope to narrow it.\n');
217
+ }
218
+ const options = {
219
+ orgId: 'org_local',
220
+ runId,
221
+ targetId,
222
+ now,
223
+ newId: counter(`${runId}_f`),
224
+ };
225
+ const findings = parseFindings(engine, text, options, file);
226
+ const snapshot = buildSnapshot({
227
+ target: {
228
+ id: targetId,
229
+ orgId: 'org_local',
230
+ name: str(values, 'target') ?? previous?.target.name ?? hostOf(url) ?? 'Target',
231
+ url: url ?? 'unknown',
232
+ },
233
+ run: {
234
+ id: runId,
235
+ orgId: 'org_local',
236
+ targetId,
237
+ kind: oneOf(values, 'run-kind', RUN_KINDS) ?? 'upload',
238
+ trigger: 'cli',
239
+ coverage: { paths: paths.length > 0 ? paths : ['**'], engines: [engine] },
240
+ startedAt: now,
241
+ },
242
+ findings,
243
+ ...(previous === undefined ? {} : { previous }),
244
+ now,
245
+ newId: counter(`${runId}_i`),
246
+ });
247
+ return { snapshot, engine, findings };
248
+ }
249
+ /** Ids within one run, numbered in order. The run id keeps them unique. */
250
+ function counter(prefix) {
251
+ let n = 0;
252
+ return () => `${prefix}${++n}`;
253
+ }
254
+ function parseFindings(engine, text, options, file) {
255
+ try {
256
+ switch (engine) {
257
+ case 'nuclei':
258
+ return importNuclei(text, options);
259
+ case 'zap':
260
+ return importZap(text, options);
261
+ case 'burp':
262
+ return importBurp(text, options);
263
+ case 'nessus':
264
+ return importNessus(text, options);
265
+ case 'generic':
266
+ return importGeneric(text, options);
267
+ }
268
+ }
269
+ catch (error) {
270
+ throw new CliError(`\`${file}\` is not valid ${engine} output: ${error instanceof Error ? error.message : String(error)}`, EXIT.input);
271
+ }
272
+ }
273
+ function loadSnapshot(io, path) {
274
+ const text = read(io, path);
275
+ try {
276
+ return parseSnapshot(text);
277
+ }
278
+ catch (error) {
279
+ throw new CliError(`\`${path}\` is not a snapshot: ${error instanceof Error ? error.message : String(error)}`, EXIT.input);
280
+ }
281
+ }
282
+ function parseNow(values, io) {
283
+ const raw = str(values, 'now');
284
+ if (raw === undefined)
285
+ return io.now();
286
+ const now = new Date(raw);
287
+ if (Number.isNaN(now.getTime())) {
288
+ throw new CliError(`--now is not a date: \`${raw}\``, EXIT.usage);
289
+ }
290
+ return now;
291
+ }
292
+ /** The host of a URL, for a report cover, when nothing better was given. */
293
+ function hostOf(url) {
294
+ if (url === undefined)
295
+ return undefined;
296
+ try {
297
+ return new URL(url).host;
298
+ }
299
+ catch {
300
+ return undefined;
301
+ }
302
+ }
303
+ /** Writes to `--out` or to stdout. */
304
+ function emit(values, io, content) {
305
+ const out = str(values, 'out');
306
+ if (out === undefined)
307
+ io.out(content.endsWith('\n') ? content : `${content}\n`);
308
+ else
309
+ io.writeFile(out, content);
310
+ }
311
+ function report(values, rest, io) {
312
+ const [kindArg, ...files] = rest;
313
+ if (kindArg === undefined) {
314
+ throw new CliError(`expected a report kind: ${REPORT_KINDS.join(', ')}.`, EXIT.usage);
315
+ }
316
+ if (!REPORT_KINDS.includes(kindArg)) {
317
+ throw new CliError(`unknown report kind \`${kindArg}\`. Expected ${REPORT_KINDS.join(', ')}.`, EXIT.usage);
318
+ }
319
+ const kind = kindArg;
320
+ const file = onlyPositional(files, 'file to report on');
321
+ // Checked here rather than left to the model, which refuses the same thing
322
+ // a few steps later in the vocabulary of a library caller. Failing before a
323
+ // file is read also means the message arrives immediately.
324
+ if (kind === 'retest' && str(values, 'previous') === undefined) {
325
+ throw new CliError('a retest compares two runs, so it needs --previous <snapshot.json> — a snapshot ' +
326
+ 'written by an earlier `secureport import`. There is nothing to compare one run to.', EXIT.usage);
327
+ }
328
+ const format = oneOf(values, 'format', FORMATS) ?? 'md';
329
+ const { snapshot } = load(values, file, io);
330
+ const options = {
331
+ kind,
332
+ now: parseNow(values, io),
333
+ ...(str(values, 'title') === undefined ? {} : { title: str(values, 'title') }),
334
+ ...(str(values, 'prepared-by') === undefined
335
+ ? {}
336
+ : { preparedBy: str(values, 'prepared-by') }),
337
+ ...(str(values, 'prepared-for') === undefined
338
+ ? {}
339
+ : { preparedFor: str(values, 'prepared-for') }),
340
+ };
341
+ let rendered;
342
+ try {
343
+ rendered =
344
+ format === 'md'
345
+ ? renderMarkdown(snapshot, options)
346
+ : format === 'html'
347
+ ? renderHtml(snapshot, options)
348
+ : renderJson(snapshot, options);
349
+ }
350
+ catch (error) {
351
+ // The one case that lands here is a retest with no baseline, which the
352
+ // model refuses rather than rendering a comparison against nothing.
353
+ throw new CliError(error instanceof Error ? error.message : String(error), EXIT.input);
354
+ }
355
+ const snapshotOut = str(values, 'snapshot-out');
356
+ if (snapshotOut !== undefined)
357
+ io.writeFile(snapshotOut, `${JSON.stringify(snapshot, null, 2)}\n`);
358
+ emit(values, io, rendered);
359
+ return EXIT.ok;
360
+ }
361
+ function importCommand(values, rest, io) {
362
+ const file = onlyPositional(rest, 'file to import');
363
+ const { snapshot } = load(values, file, io);
364
+ emit(values, io, `${JSON.stringify(snapshot, null, 2)}\n`);
365
+ return EXIT.ok;
366
+ }
367
+ /**
368
+ * Says what is in a file without producing anything from it.
369
+ *
370
+ * The command somebody runs when an import failed and they want to know
371
+ * whether the file or the flag was wrong, so it reports what it detected and
372
+ * what it read rather than only whether it parsed.
373
+ */
374
+ function validate(values, rest, io) {
375
+ const file = onlyPositional(rest, 'file to validate');
376
+ const { engine, findings, snapshot } = load(values, file, io);
377
+ const counts = new Map();
378
+ for (const finding of findings) {
379
+ counts.set(finding.detectedSeverity, (counts.get(finding.detectedSeverity) ?? 0) + 1);
380
+ }
381
+ const bySeverity = [...counts.entries()].map(([severity, n]) => `${n} ${severity}`).join(', ');
382
+ io.out([
383
+ `${file}: valid ${engine} output`,
384
+ ` ${findings.length} finding${findings.length === 1 ? '' : 's'}${bySeverity === '' ? '' : ` — ${bySeverity}`}`,
385
+ ` ${snapshot.issues.length} issue${snapshot.issues.length === 1 ? '' : 's'} after reconciliation`,
386
+ '',
387
+ ].join('\n'));
388
+ return EXIT.ok;
389
+ }
390
+ //# sourceMappingURL=run.js.map