# PRD — vulnerability_scan

Evidence date: 2026-09-29.

Source: `git@bitbucket.org:overdrive/vulnerability_scan.git`, `main` at `de29c65` (2025-04-02).

There is no prior PRD in the source repository or in Silicon Ark. Requirements below are recovered from `readme.md`, `cli_app/cli.py`, the compose file, and git history. Provenance is labelled. Credential values are omitted.

## Product purpose

**Documented.** `readme.md` describes a command-line tool that runs Greenbone vulnerability scans against a target and emails generated reports, and that lets an operator set scan options for that target.

**Implemented.** `cli_app/cli.py` authenticates to gvmd, ensures a target and a task exist, starts or reuses a scan, waits until the task is not in use, loads the XML report, builds an HTML document, and optionally emails it or inserts it into Supabase.

**Unknown / Requires Confirmation:** whether this CLI is the only way scans are started, which targets are on a standing schedule, and who is expected to read the mail.

## Users and roles

| Actor | Evidence | Status |
|---|---|---|
| Operator | Runs `cli.py` or `green_bone_docker/cli_app.sh`. README examples use hostnames `timesheets.php8` and `eit.php8` and mailboxes `joshua@overdrive.co.za` and `jaco@overdrive.co.za` | **Documented** as the CLI user. That those addresses are the production audience is **Inferred** |
| GVM account | `.env` keys `USERN` and `PASS`, or an interactive username and hidden password. Comment in `cli.py` shows the example user `admin` | **Implemented**. The example password in the comment is a placeholder and is not treated as a live secret here |
| Greenbone web user | README points at `http://127.0.0.1:9392/login` for port lists, scan configs, and scanners | **Documented**. GSA is published on `127.0.0.1:9392` |
| Mail recipient | `--mail` colon-separated addresses | **Implemented** |
| Application role / staff role | No role check in the CLI beyond the single GVM login | **Not implemented** |

The committed file `cli_app/test.json` is a Greenbone report whose owner name is `admin` and whose task name is `timesheets.php8`. The CLI does not load this file. It is a sample, not a user record.

## Systems the product talks to

1. Greenbone gvmd over the GMP protocol (python-gvm), using a Unix socket on the active path.
2. Microsoft 365, through the `O365` library, when `--mail` is set and a report with at least one host is produced.
3. Supabase, table `scan_que`, when `--supabase` is set on that same path.
4. SonarQube from Bitbucket Pipelines on `main`. That path does not run a scan.

SSH and TLS connection constructors exist in `create_connection`. The script always calls `create_connection` with `connection_type="socket"`. The SSH call is commented out. TLS is never selected. Provenance: **Implemented**.

## Major workflows

### WF-1 — Start the Greenbone stack

**Documented** in `readme.md` for Ubuntu: install Docker, then `green_bone_docker/install_greenbone.sh`, then `start.sh`.

**Implemented:**

- `install_greenbone.sh` creates `/tmp/gvm/gvmd` with mode `777`, runs `docker compose build`, then `docker compose pull`. It does not start the stack.
- `start.sh` creates the same directory and mode, then `docker compose up -d`.
- `stop.sh` runs `docker compose down`.
- `streamlogs.sh` follows compose logs.
- `restart.sh` runs `docker compose up -d` and does not stop containers first.

GSA listens on `127.0.0.1:9392`. Feed-sync services use `FEED_RELEASE` `24.10`.

### WF-2 — Invoke the CLI

**Documented** usage is `./cli-app.py <target_url> [options]`.

**Discrepancy:** the file on disk is `cli_app/cli.py`. The Docker entrypoint runs `./cli.py`. There is no `cli-app.py`.

**Implemented** invocation rules:

- Fewer than two `sys.argv` entries prints `Must supply target. For help and usage, use --help` and exits `2`.
- The first argument is the target list, split on `:`.
- If everything after the target arrives as one string, the script splits it on `--` and on selected short options. The short-option delimiters include ` -d`, ` -p`, ` -c`, ` -s`, ` -n`, ` -k`, ` -h`, and `-z`. They do not include ` -m`.
- `-h` / `--help` prints usage and exits before any Greenbone connection.
- Option values have `_` replaced with a space, and single and double quotes removed.
- `--defaults` sets port list `All IANA assigned TCP`, config `Full and fast`, and scanner `OpenVAS Default`.
- A later `--portlist`, `--config`, or `--scanner` in getopt order replaces the matching default, because the option loop assigns those variables as it goes.

`green_bone_docker/cli_app.sh` runs image `greenbone-community-edition-cli-app` with `--rm -it`, mounts host `/tmp/gvm/gvmd` onto container `/run/gvmd`, and passes the script arguments with `$*`.

### WF-3 — Authenticate to gvmd

**Implemented** in the GMP block:

1. Choose the socket. The path starts as `/run/gvmd/gvmd.sock`. If `/tmp/gvm/gvmd/gvmd.sock` is a file, that path is used instead.
2. Print the version from `get_version`.
3. Take `USERN` and `PASS` from the environment when those keys exist. Otherwise prompt, using `getpass` for the password.
4. Call `gmp.authenticate`. If the response status attribute is not `400`, stop retrying. Otherwise retry, up to three attempts, then print the response and exit `2`.
5. Clear the local `password` variable after each attempt. The environment value is left as it was.

**Inferred:** a `.env` that holds the wrong password fails three times without a new prompt, because the prompt is skipped whenever the environment keys exist.

**Discrepancy:** `readme.md` says the default socket is `/tmp/gvm/gvmd/gvmd.sock` and tells the reader to uncomment line 357 and comment line 355 to use SSH. At `de29c65` the socket choice is lines 384–387 and the SSH call is a comment at line 389. `ms_tenant_id` is read later and is not part of this login.

### WF-4 — Ensure a target and a task

For each name in the target list:

1. `get_targets` with `filter_string` set to that name. A row is a match only when `name` equals the target string.
2. If none matches, resolve a port list (CLI value or an interactive menu from `get_port_lists`) and `create_target` with `hosts=[target]`, that port list, and alive test `ICMP, TCP-ACK Service & ARP Ping`. Status `201` stores the new id. Any other status prints `Unable to add target` and exits `2`.
3. If a target already exists, the port list argument is not applied.
4. `get_tasks` with no filter. A row is a match only when `name` equals the target string.
5. If none matches, resolve a scan config and a scanner (CLI values or menus) and `create_task`. Status `201` stores the id. Any other status exits `2`.
6. If a task already exists, `--config` and `--scanner` are not applied.

Menus come from `simple-term-menu`. A CLI name that is not in the Greenbone list raises a KeyError (the lookup is `options_dict[cli_opt]` with no fallback). An empty Greenbone list prints a configuration message and exits `2`.

### WF-5 — Run, attach, or skip

For each target, after WF-4:

- Load the task.
- If `--skipscan` is absent, or the task status is `Running`:
  - Status `Running`: print that it is already running and take `current_report.report.id`.
  - Otherwise: `start_task` and take `report_id`.
- If `--skipscan` is set and the task is not `Running`: take `last_report.report.id` when that node exists. Otherwise the report id stays false.
- Poll while `in_use` is `"1"`. Each pass calls `get_task` and draws a progress bar from `progress`. On exception, count up to 10 failures, print the error, then exit `2` with a message that the scan itself continues and that running the command again can attach. Between polls, sleep 20 seconds while still in use.
- If the scan was not skipped, print a completed bar. If it was skipped, print `Skipping Scan`.

**Implemented consequence:** `--skipscan` does not skip the wait when the task status is already `Running`, because that status forces the attach branch.

### WF-6 — Build the HTML report

When a report id was obtained:

1. `get_report_formats` and select the format whose name is `XML`. If it is missing, exit `2`.
2. `get_report` with that format, `ignore_pagination=True`, and `details=True`.
3. Status `400`, a missing report node, or an empty report exits `2` with a message about empty results or missing report tools.
4. If `report.report.hosts.count` is an integer greater than 0, build HTML:
   - Title `Security Scan Report: <task name>`.
   - Intro text states that dates are Coordinated Universal Time (UTC). That sentence is a fixed string. The code does not read the report's `timezone` field.
   - Overview table of severity counts, then a host section with scan start and scan end.
   - Results grouped into High, Medium, Low, Log, and a fifth bucket when `threat` equals the literal `False`.
   - Each result uses NVT tags `summary`, `insight`, `solution_type`, and `solution`, plus QoD, CVSS base, description, and refs. A ref whose type is `url` becomes a link.
5. If the host count is not greater than 0, print that the target is not reachable and suggest changing the alive test. No mail is sent for that target.

The block that would write `scan_results.html` is commented out. Provenance: **Implemented** as inactive code.

`cli_app/test.json` is one captured report (GMP version `22.6`, task `timesheets.php8`, one host, scan start `2025-03-03T05:51:49Z`). It is not an input to this workflow. Its filtered results use threat values `Low` and `Medium` only, so it does not show which string Greenbone uses for a false positive.

### WF-7 — Email the report

**Documented:** `--mail` is a colon-separated list. The README also lists SMTP keys `mail_host`, `mail_port`, `mail_user`, `mail_pass`, and `mail_from`.

**Implemented:** those SMTP keys are not read. `send_email` is commented out. The active function is `send_ms_email`.

When the HTML path runs and `len(mail_to) > 0`, the script calls `send_ms_email` with subject `<task name> Security Scan` and the HTML string as the message body, then prints `Report sent!`.

`send_ms_email`:

- Reads `ms_tenant_id`, `ms_app_id`, and `ms_app_secret_val` from the environment. `ms_tenant_id` is not passed into `Account`.
- Scopes requested: `IMAP.AccessAsUser.All`, `POP.AccessAsUser.All`, `SMTP.Send`, `Mail.Send`, `offline_access`.
- If `./o365_token.txt` does not exist, constructs `Account(credentials)` and calls `authenticate(scopes)`.
- Then loads `FileSystemTokenBackend` from `./o365_token.txt` and sends with `auth_flow_type='authorization'`.

**Implemented gap:** `mail_to` is set only inside the `--mail` branch. If a report with hosts is produced and `--mail` was omitted, `len(mail_to)` raises `NameError`. That exception is not a `GvmError`, so the handler at the bottom of the script does not catch it.

**Unknown / Requires Confirmation:** whether the O365 library sends `m.body` as HTML. The code assigns the HTML string and does not set a separate content type.

The screenshots in `documentation/` show a Stack Overflow answer for the same library, including a redirect URL `https://login.microsoftonline.com/common/oauth2/nativeclient` and a note about admin consent. Those steps are not encoded as script logic.

### WF-8 — Store the report in Supabase

**Documented:** `-z` / `--supabase` stores scan results in Supabase. README names `SUPABASE_URL` and `SUPABASE_KEY`.

**Implemented** when the flag is set and the HTML path runs:

- Read `SUPABASE_URL` into `supabase_url`.
- If `SUPABASE_KEY` is set, assign that key to `supabase_url` (the URL variable). `supabase_pass` is assigned the empty string and is not used.
- `create_client(supabase_url, supabase_url)`.
- Insert into `scan_que` with `type` `greenbone`, `command` equal to the joined process arguments, `created_at` / `start_at` / `end_at` taken from the report scan times, `results` the HTML, and `to_start`, `intervals`, and `result_summary` set to boolean false.

**Requires Confirmation:** this insert does not pass the URL and the key as two different arguments. Whether a deployed table matches these columns is not visible in this repository. Nothing here reads `scan_que`.

## Functional requirements

### FR-001 — Target is required

The CLI exits `2` when no target argument is present.

Evidence: `cli_app/cli.py` argv length check. Implementation: **Implemented**. Confidence: **High**.

### FR-002 — Several targets in one invocation

A colon-separated first argument is scanned as separate targets, each with its own target name, task name, report, and optional email.

Evidence: `target_list=args[0].split(":")` and the per-target loops. README examples `eit.php8:timesheets.php8`. Implementation: **Implemented**. Confidence: **High**.

**Requires Confirmation:** a hostname that itself contains a colon (for example `host:443`) is split into two targets. The README examples do not include ports.

### FR-003 — First-time setup can be interactive or defaulted

Without `--defaults` and without `--portlist` / `--config` / `--scanner`, creating a missing target or task opens a menu of live Greenbone names. `--defaults` selects `All IANA assigned TCP`, `Full and fast`, and `OpenVAS Default`.

Evidence: `readme.md` options; `get_options`; the `--defaults` branch. Implementation: **Implemented**. Confidence: **High**.

### FR-004 — Existing target and task keep their stored options

A matching name does not call an update API. Port list, config, and scanner flags apply only on create.

Evidence: the `found_target` / `found_task` branches. Implementation: **Implemented**. Confidence: **High**.

### FR-005 — New targets use a fixed alive test

`create_target` always sends alive test `ICMP, TCP-ACK Service & ARP Ping`.

Evidence: `gmp.create_target` call. Implementation: **Implemented**. The README does not name this value. Confidence: **High**.

### FR-006 — The CLI waits until the task is idle

While `in_use` is `1`, the CLI refreshes the task, prints progress, and sleeps 20 seconds. Ten status errors end the CLI with exit `2` and leave the Greenbone task running.

Evidence: the wait loop and the error text in `cli.py`. Implementation: **Implemented**. Confidence: **High**.

### FR-007 — A running task is joined, not started again

If task status is `Running`, the CLI uses `current_report` and waits. It does not call `start_task`.

Evidence: WF-5. Implementation: **Implemented**. Confidence: **High**.

### FR-008 — Skipscan loads the previous report

`--skipscan` (`-k`) uses `last_report` when the task is not running. The flag is described in the `--help` text. It is not listed in the README options section.

Evidence: getopt `--skipscan`; help string. Implementation: **Implemented**. Documentation: **partial**. Confidence: **High**.

### FR-009 — Unreachable targets do not produce a report email

A report whose host count is not greater than 0 prints a not-reachable message and does not mail or insert.

Evidence: the `hosts.count` branch, added in spirit by commit `06d81ec`. Implementation: **Implemented**. Confidence: **High**.

### FR-010 — HTML groups findings by threat

Findings are grouped into High, Medium, Low, Log, and threat value `False`.

Evidence: the section builders in `cli.py`. Implementation: **Implemented**. Confidence: **High** for the five comparisons. Confidence: **Low** that Greenbone's false-positive label is the string `False`. The sample report does not contain one. **Requires Confirmation**.

### FR-011 — Mail uses Microsoft 365, not the SMTP block

`--mail` sends through `send_ms_email`. The SMTP function and the `mail_*` environment loader are comments.

Evidence: `cli.py` and `readme.md`. Implementation: **Implemented** for O365. The README SMTP block is **Documented** and not active. Confidence: **High**.

### FR-012 — Supabase is optional and best-effort in source

`--supabase` inserts one row into `scan_que` after a non-empty host report. The client is constructed from one overwritten variable.

Evidence: the insert block. Implementation: **Implemented** as written. Whether it succeeds against a real project is **Unknown / Requires Confirmation**.

### FR-013 — Help does not contact Greenbone

`--help` prints usage and exits before the socket connection.

Evidence: the getopt loop calls `sys.exit()` with no status argument (Python default `0`) on the help branch. Implementation: **Implemented**. Confidence: **High**.

## Constraints

- Option values cannot contain spaces. The README and the help text both say to use underscores. The script converts them back.
- Port list, config, and scanner names must already exist in that Greenbone installation. They are not created by this CLI.
- The active connection is a local gvmd socket. Remote SSH and TLS are not on the path that runs.
- GSA is bound to loopback port 9392 in the compose file.
- `.env` and `o365_token.txt` are gitignored. They are not mounted by `cli_app.sh`. How a container run receives them is **Unknown / Requires Confirmation**.
- The pipeline has no deployment and no test stage.

## Error conditions

| Condition | Behaviour | Exit |
|---|---|---|
| No target argument | Message, stop | `2` |
| getopt failure | Print the getopt error | `2` |
| Help | Usage text | `0` |
| GVM auth status stays `400` for three tries | Print failure and the response | `2` |
| Named port list, config, or scanner missing from the API list | KeyError, not caught as `GvmError` | Uncaught |
| Empty port list, config, or scanner catalogue | Configuration message | `2` |
| `create_target` or `create_task` status is not `201` | Unable to add | `2` |
| Ten errors while polling | Message that the scan continues | `2` |
| XML report format missing, or report status `400`, or empty report | Report message | `2` |
| Host count not above 0 | Not-reachable message, no mail | Process continues to the next target, then ends without a forced non-zero code |
| No report id (skip with no last report) | `Unable to find a report...` | No forced non-zero code |
| `GvmError` | Print `An error occurred` and the exception | No `sys.exit`; Python then exits `0` if nothing else is raised |
| `--mail` omitted and a host report exists | `NameError` on `mail_to` | Uncaught |
| Missing NVT tag `summary`, `insight`, `solution_type`, or `solution` | `KeyError` | Uncaught |

## Non-requirements (looked for, not found)

- User accounts inside this application, other than the GVM login.
- Approval before a scan starts.
- A severity threshold that suppresses mailing.
- Retention, deletion, or access control for mailed HTML.
- A consumer of `scan_que`.
- A test suite or a stated coverage target.
