@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 +21 -0
- package/README.md +61 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +29 -0
- package/dist/cli.js.map +1 -0
- package/dist/detect.d.ts +21 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +65 -0
- package/dist/detect.js.map +1 -0
- package/dist/run.d.ts +44 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +390 -0
- package/dist/run.js.map +1 -0
- package/package.json +54 -0
- package/src/cli.ts +31 -0
- package/src/detect.ts +64 -0
- package/src/run.ts +503 -0
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 @@
|
|
|
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
|
package/dist/cli.js.map
ADDED
|
@@ -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"}
|
package/dist/detect.d.ts
ADDED
|
@@ -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
|