This guide is the operator's, architect's, and contributor's reference for Flan Scan, Cloudflare's lightweight network vulnerability scanner. It is the companion to the User Guide: where the User Guide says "run make start," this document explains what that actually does, why the container is shaped the way it is, what every module contributes, and how to extend or operate Flan Scan in production.
The codebase is deliberately small (32 files, ~26.5 KB of source) and cleanly layered: shell orchestration → Nmap with a CVE-matching script → Python XML parser → CVE description provider → pluggable report builder. This guide is organized around those layers and the operational concerns around them.
1. Executive Summary
Flan Scan is a stateless, containerized batch scanner. Each invocation reads a list of targets from /shared/ips.txt, runs one Nmap service-detection scan per target with the vulners NSE script, parses the resulting XML, enriches matched CVEs with descriptions, and renders a single report in one of four formats. There is no daemon, no queue, no API, and no persistent state beyond the files written to the mounted shared/ volume.
| Attribute | Detail |
|---|---|
| Purpose | Network vulnerability scanning (open ports → services → CVEs). |
| Built with | Shell (run.sh), Python 3.9, Nmap + vulners NSE script, Jinja2. |
| Packaging | Single Docker image (python:3.9-alpine base). |
| Deployment shapes | Local Docker (make), any container host, Kubernetes Deployment or CronJob. |
| Persistence | None. Output written to a mounted /shared volume. |
| External calls | Outbound HTTPS to CVEProject GitHub and NIST NVD for descriptions; optional AWS S3 / GCS upload. |
| Operational burden | Low. Build image, provide ips.txt, run; results are files. |
| Runtime privilege | Minimal: --cap-drop=all --cap-add=NET_RAW. |
The design goal is operational simplicity: the unit of work is "scan these targets, produce one report," and every run is a fresh, disposable container.
2. Architecture
2.1 Component diagram
┌─────────────────────────────────────────┐
│ Docker container │
│ (python:3.9-alpine) │
/shared/ips.txt ───► │ │
(mounted volume) │ run.sh (ENTRYPOINT, orchestration) │
│ │ │
│ ├─ for each IP: │
│ │ nmap -sV --script=vulners ──────┼──► target hosts
│ │ → /shared/xml_files/<ts>/*.xml │
│ │ │
│ └─ python output_report.py │
│ │ │
│ ┌────────┴─────────────────────────┐ │
│ │ FlanXmlParser (contrib/parsers) │ │
│ │ parses Nmap XML → ScanResult │ │
│ └────────┬─────────────────────────┘ │
│ │ │
│ ┌────────┴─────────────────────────┐ │
│ │ CveProjectProvider │ │
│ │ (contrib/descriptions) ──────────┼───┼──► CVEProject GitHub
│ │ CVE id → description + NIST url │ │ + NIST NVD
│ └────────┬─────────────────────────┘ │
│ │ │
│ ┌────────┴─────────────────────────┐ │
│ │ ReportBuilder (contrib/report_ │ │
│ │ builders): Latex│Markdown│Json│ │ │
│ │ JinjaHtml │ │
│ └────────┬─────────────────────────┘ │
│ ▼ │
/shared/reports/ ◄───┤ report_<ts>.<ext> │
(mounted volume) │ │ │
│ upload() → aws_push.py / gcp_push.py ──┼──► S3 / GCS (optional)
└─────────────────────────────────────────┘
2.2 Component inventory
| Component | File(s) | Responsibility |
|---|---|---|
| Orchestrator | run.sh |
Loop targets, invoke Nmap, trigger report + upload. |
| Report entrypoint | output_report.py |
Wire parser → builder; drive the report pipeline. |
| XML parser | contrib/parsers/flan_xml_parser.py |
Convert Nmap XML into ScanResult/Vuln objects. |
| Internal types | contrib/internal_types/flan_types.py |
ScanResult, Vuln, SeverityLevels. |
| Description provider | contrib/descriptions/ |
Fetch CVE descriptions + NIST links (CveProjectProvider). |
| Report builders | contrib/report_builders/ |
Render reports: LaTeX, Markdown, JSON, Jinja HTML. |
| Cloud upload | aws_push.py, gcp_push.py |
Push files to S3 / GCS. |
| Packaging | Dockerfile, Makefile |
Build image; run scans with the correct flags. |
| K8s manifests | kubernetes_templates/ |
Deployment, CronJob, Kustomization. |
2.3 Data flow
run.shreads/shared/ips.txtline by line.- For each line it runs
nmap -sV -oX <xml> -oN - -v1 $@ --script=vulners/vulners.nse <ip>, writing XML to/shared/xml_files/<timestamp>/<ip>.xml. - After all targets are scanned,
run.shcallspython /output_report.py <xml_dir> <report_file> /shared/ips.txt. output_report.pyselects aReportBuilderfrom theformatenv var, parses every XML file withFlanXmlParser, and streams sections into the builder.- The builder's
build()returns the rendered report, which is written to/shared/reports/report_<timestamp>.<ext>. - For LaTeX output,
run.shpost-processes the file withsedto escape_,$,#,%. - If
uploadis set,run.shcallsaws_push.pyorgcp_push.pyfor both XML and report.
3. System Requirements
| Requirement | Detail |
|---|---|
| Host | Any Docker-capable host (Linux recommended; x86-64). |
| Docker | Required at build and run time. |
make |
Optional convenience for the provided targets. |
| CPU / memory | Modest; scan cost is dominated by Nmap and network latency, not local compute. |
| Network (build) | Outbound access to Alpine package repos, GitHub (nmap-vulners). |
| Network (run) | Reachability to scan targets; outbound HTTPS to CVEProject/NIST for descriptions; outbound to AWS/GCP if uploading. |
| Privileges | NET_RAW capability (raw sockets for Nmap). All other capabilities dropped. |
Pinned language/runtime versions:
| Component | Version |
|---|---|
| Base image | python:3.9-alpine |
xmltodict |
0.12.0 |
google-cloud-storage |
1.23.0 |
boto3 |
1.12.15 |
Jinja2 |
2.11.3 |
markupsafe |
2.0.1 |
Nmap + nmap-scripts |
Alpine package (latest at build) |
vulners NSE |
Cloned from github.com/vulnersCom/nmap-vulners at build |
Warning
output_report.pyandcontrib/descriptions/cveproject.pyimport therequestslibrary, butrequestsis not listed inrequirements.txt. It is currently satisfied only as a transitive dependency ofboto3/google-cloud-storage. See Potential Improvements.
4. Configuration Mastery
Flan Scan has no config file. All configuration is environment variables plus the ips.txt target list. The table below is the complete, source-based reference.
| Variable | Read at | Values | Default | Effect |
|---|---|---|---|---|
format |
output_report.py (os.getenv('format', 'tex')) |
tex, md, html, json |
tex |
Selects the ReportBuilder. Unknown value → NotImplementedError. |
upload |
run.sh |
aws, gcp |
(unset) | Chooses the upload path in run.sh's upload() function. Unset = no upload; changes root_dir to /. |
bucket |
aws_push.py:9, gcp_push.py (os.getenv('bucket')) |
bucket name | (none) | Destination S3/GCS bucket. |
GOOGLE_APPLICATION_CREDENTIALS |
GCS client (google-cloud-storage) | path | (none) | Path to GCS service-account JSON key. |
AWS_ACCESS_KEY_ID |
AWS SDK (boto3) | key id | (none) | AWS credential. |
AWS_SECRET_ACCESS_KEY |
AWS SDK (boto3) | secret | (none) | AWS credential. |
Target list — /shared/ips.txt: one IP or CIDR per line. Read by run.sh (to iterate scans) and by output_report.py (to list scanned IPs in the report). Must end with a newline or the final entry is skipped.
Important
The
uploadvariable does more than toggle uploads. Inrun.sh, whenuploadis unset,root_dir=/shared/; when it is set,root_dir=/and the script creates/xml_filesand/reportsat the container root. This is why the Kubernetes templates — which setupload— expect output at/rather than/shared.
5. Deployment
5.1 Docker
Build and run locally with the Makefile (see the Command Reference). The canonical run command is:
docker run --rm --cap-drop=all --cap-add=NET_RAW \
--name flan_<timestamp> \
-v "$(CURDIR)/shared:/shared:Z" \
flan_scan
Key flags:
--rm— container is disposable; removed on exit.--cap-drop=all --cap-add=NET_RAW— drop every Linux capability except raw-socket access.-v ...:/shared:Z— mount the working directory;:Zapplies the SELinux relabel needed on SELinux hosts.
5.2 Kubernetes
The kubernetes_templates/ directory provides three manifests:
| File | Kind | Purpose |
|---|---|---|
deployment.yaml |
Deployment (1 replica) |
Long-lived pod running Flan Scan. |
cron_job.yaml |
CronJob (batch/v1beta1) |
Scheduled recurring scans. |
kustomization.yaml |
Kustomization |
Generates the shared-files ConfigMap from a local ips.txt. |
Both workloads mount two volumes:
cloud-key— aSecret(cloudkey-key/cloud-key) mounted at/var/secrets/googlefor cloud credentials.shared-files— aConfigMapmountingips.txtat/shared.
Environment placeholders to fill in the manifests: upload (<GCP_OR_AWS>), bucket (<BUCKET_NAME>), format (<REPORT_FORMAT>), plus the container image reference.
Deploy steps:
- [ ] Add a local
ips.txtpath tokustomization.yaml. - [ ]
kubectl apply -k .to create theshared-filesConfigMap. - [ ]
kubectl get configmapto verify the ConfigMap exists. - [ ] Set
upload,bucket,format, image, and secrets indeployment.yaml(orcron_job.yaml). - [ ]
kubectl apply -f deployment.yaml(orcron_job.yaml).
Note
cron_job.yamlusesapiVersion: batch/v1beta1, which is removed in Kubernetes 1.25+. On modern clusters, change it tobatch/v1. See Potential Improvements.
Caution
The
CronJobandDeploymenttemplates do not set--cap-drop=all --cap-add=NET_RAW— that hardening is applied only by theMakefilefor local runs. When deploying to Kubernetes, add an explicitsecurityContextto drop all capabilities and add onlyNET_RAW.
6. Security
6.1 Runtime privilege
Local runs drop all Linux capabilities and add back only NET_RAW, which Nmap needs for raw-socket scan types (-sV service detection, SYN scans). This is a strong default: the container cannot, for example, change file ownership, load kernel modules, or bind privileged ports beyond what raw sockets allow.
6.2 Secrets handling
- Cloud credentials are supplied as environment variables (
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY) or a mounted key file (GOOGLE_APPLICATION_CREDENTIALSpointing at a JSON key, typically placed inshared/). - In Kubernetes, credentials come from a mounted
Secretat/var/secrets/google.
Warning
The GCS workflow instructs copying the service-account key into
shared/(cp key.json shared/). Becauseshared/is a host-mounted volume, that private key now lives on the host filesystem and inside any report bucket if accidentally uploaded. Treatshared/as sensitive: restrict its permissions and never commitkey.json.
6.3 Input validation & network exposure
- No inbound surface. Flan Scan exposes no ports and serves no requests; it is a client that scans outward. There is no listener to attack.
- Target input (
ips.txt) is trusted operator input passed to Nmap. Do not feed untrusted content intoips.txt, as extra Nmap flags can be injected via the container's argument passthrough ($@). - Outbound calls go to CVEProject GitHub and NIST for descriptions; these are read-only GETs. A failure degrades gracefully (blank description) rather than aborting the report.
- Report content is derived from scan output and third-party CVE descriptions. LaTeX output escapes
_,$,#,%; other formats rely on their libraries (Jinja2 autoescaping is enabled for HTML).
Important
Only scan networks you are authorized to scan. Flan Scan performs active service detection, which is intrusive and may be logged or flagged by target networks.
7. Operations
7.1 Start / stop
There is nothing to "stop" — each run is a one-shot container that exits when the report is written (--rm removes it). To stop a CronJob, delete or suspend it (kubectl patch cronjob flan-scan-job -p '{"spec":{"suspend":true}}').
7.2 Health & output verification
Success is observable by artifact:
shared/xml_files/<timestamp>/contains one XML per reachable target.shared/reports/report_<timestamp>.<ext>exists and is non-empty.
If the report is empty or missing services, inspect the raw XML to confirm Nmap detected open ports and versions.
7.3 Upgrades
Rebuild the image (make build, which is --no-cache) to pull the latest Nmap, nmap-scripts, and vulners script. There is no in-place upgrade path; the image is the unit of versioning.
7.4 Capacity
Scan wall-clock time scales with the number of targets and network latency, not local CPU. For large target sets, prefer splitting ips.txt across parallel CronJobs or hosts rather than one very long serial run (run.sh scans targets sequentially).
8. Performance Tuning
- Serial scanning:
run.shiterates targets one at a time. For large ranges, shardips.txtand run multiple containers concurrently. - Nmap flags: Pass timing templates (
-T4) or reduced probes via the argument passthrough to speed up scans at some accuracy cost, e.g.docker run ... flan_scan -T4. - Description caching:
CveProjectProvidercaches descriptions in-memory per run (self.cache), so repeated CVEs across services are fetched once. This cache does not persist between runs. - Build caching:
make buildintentionally disables the Docker cache. If you need faster iterative builds during development, build withdocker builddirectly (without--no-cache), accepting potentially stale vulnerability data.
9. Integrations
| Integration | Mechanism | Notes |
|---|---|---|
| AWS S3 | boto3 in aws_push.py (s3_client.upload_file) |
Uses bucket env var + AWS credential env vars. |
| Google Cloud Storage | google-cloud-storage in gcp_push.py |
Uses bucket env var + GOOGLE_APPLICATION_CREDENTIALS. |
| CVEProject / NIST | requests GET in CveProjectProvider |
CVE descriptions and NIST detail links. |
| Kubernetes | Manifests in kubernetes_templates/ |
Deployment, CronJob, ConfigMap via Kustomize. |
| Downstream tooling | JSON report (make json) |
Machine-readable output for dashboards/pipelines. |
10. Developer Internals
10.1 Repository structure
flan/
├── run.sh # ENTRYPOINT orchestrator (scan loop + report + upload)
├── output_report.py # Report pipeline entrypoint
├── aws_push.py / gcp_push.py # Cloud upload helpers
├── Dockerfile / Makefile # Packaging and run targets
├── requirements.txt # Python deps
├── shared/
│ ├── ips.txt # Target list
│ ├── reports/ # Output reports (gitignored)
│ └── xml_files/ # Raw Nmap XML (gitignored)
├── kubernetes_templates/ # Deployment, CronJob, Kustomization
└── contrib/
├── parsers/ # FlanXmlParser
├── internal_types/ # ScanResult, Vuln, SeverityLevels
├── descriptions/ # VulnDescriptionProvider, CveProjectProvider
└── report_builders/ # ReportBuilder + 4 concrete builders + Jinja template
10.2 The report-builder pattern
The core design is a builder abstraction (ReportBuilder, contrib/report_builders/report_builder.py) with an ordered protocol that output_report.py:create_report() drives regardless of format:
init_report → add_vulnerable_section → initialize_section → add_vulnerable_services
→ add_non_vulnerable_section → initialize_section → add_non_vulnerable_services
→ add_ips_section → add_ip_address (per IP) → finalize → build
Concrete builders:
| Builder | Class | Base | Notes |
|---|---|---|---|
| LaTeX | LatexReportBuilder |
ReportBuilder |
Colored severity tables; embeds a full LaTeX preamble. |
| Markdown | MarkdownReportBuilder |
ReportBuilder |
GitHub-style Markdown with severity + links. |
| JSON | JsonReportBuilder |
ReportBuilder |
Structured {ips, vulnerable, not_vulnerable} buffer. |
| HTML | JinjaHtmlReportBuilder |
JsonReportBuilder |
Reuses JSON's data buffer, renders via Jinja2 template templates/jinja2_report.html with autoescaping. |
create_report_builder(report_type) in output_report.py maps format → builder via a dict; an unknown format raises NotImplementedError(report_type).
Tip
To add a new report format, subclass
ReportBuilder(orJsonReportBuilderif you want the pre-structured buffer), implement the protocol methods, and add one entry to thebuilder_mapdict increate_report_builder. No other file needs to change.
10.3 XML parsing (FlanXmlParser)
FlanXmlParser converts Nmap XML (via xmltodict) into ScanResult objects keyed by service/app name. Key behaviors verified in source:
- Handles both single-host and multi-host XML (list vs. dict) — a common Nmap XML shape difference.
- Skips ports whose state is
closed; labels services with no name asunknown(guards the historicalKeyError, issue #46). - Parses vulnerabilities only from the
vulnersscript output, and only the first time an app is seen (dedup by app name). get_app_name()builds a stable identifier from@product,@version, and CPE entries.
10.4 Types & severity
contrib/internal_types/flan_types.py defines:
Vuln(name, vuln_type, severity)withto_dict()and CVSS→label mapping (convert_severity:<4Low,<7Medium, else High).ScanResult— per-servicelocations(ip → [ports]) andvulnslist.SeverityLevels— string constantsLow/Medium/High.
10.5 Description provider
CveProjectProvider (implements the abstract VulnDescriptionProvider) fetches a CVE's description from raw.githubusercontent.com/CVEProject/cvelist/... and pairs it with a NIST NVD URL. HTTP errors are caught and returned as a blank description with an error note, so report generation never aborts on a failed lookup.
10.6 Testing strategy
There is no test suite in the repository (no tests/ directory, no CI config in source). The abstraction seams — ReportBuilder and VulnDescriptionProvider are both abc.ABCMeta interfaces, and FlanXmlParser is pure (XML in, objects out) — make the code straightforward to unit-test. This is the single biggest contribution opportunity (see below).
11. Troubleshooting
Error: NotImplementedError: <format>
- Cause:
create_report_builderwas called with aformatnot in{tex, md, html, json}(output_report.py). - Solution: Set
formatto a supported value. - Prevention: Validate the
formatenv var / manifest field against the four supported formats.
Error: mkdir: can't create directory '/shared/xml_files/...': Permission denied
- Cause: The mounted volume is not writable by the container (SELinux labeling or host permissions; issues #73, #10).
- Solution: Use the
:Zmount flag (as theMakefiledoes) on SELinux hosts; ensureshared/,shared/reports/, andshared/xml_files/are writable. - Prevention: Deploy via the provided
maketargets or replicate their mount flags.
Error: Run-time Error: No Launch Config Set
- Cause: Container started outside the expected invocation pattern (issue #84).
- Solution: Use
make start/make md|html|jsonor the documenteddocker runwith/sharedmounted and env vars set. - Prevention: Standardize on the documented run commands / Kubernetes manifests.
Error: KeyError during report generation
- Cause: Historical parser crash on hosts that are down or return unexpected XML (issue #46).
- Solution: Rebuild from current source, which labels unknown services rather than crashing.
- Prevention: Keep the image rebuilt (
make build).
Error: LaTeX report fails to compile
- Cause: Special characters in CVE summaries (issue #33). Flan escapes
_,$,#,%but not all constructs. - Solution: Use
make md/make html/make json, or sanitize the offending summary. - Prevention: Prefer non-LaTeX formats for automated pipelines.
Error: connection error / blank descriptions
- Cause: No outbound access to CVEProject/NIST (issue #52).
- Solution: Provide outbound HTTPS; blank descriptions are non-fatal.
- Prevention: Run in a network with egress, or accept missing descriptions.
Problem: Last target skipped
- Cause:
ips.txtlacks a trailing newline (issue #21). - Solution / Prevention: Ensure a trailing newline.
Error: free(): invalid pointer on make build
- Cause: Environment/architecture-specific build failure (issue #4).
- Solution: Rebuild; try a different host architecture / Docker version.
- Prevention: Build on a supported x86-64 host.
Problem: Uploads silently do nothing
- Cause:
aws_push.pycatches all upload exceptions and printsError uploading to s3without failing the run; a missingbucketor bad credential yields no uploaded file but a zero exit. - Solution: Check container stdout for
Error uploading to s3; verifybucketand credentials. - Prevention: Validate credentials and bucket before scheduling.
12. Potential Improvements
Source-based tech-debt and enhancement opportunities:
- Missing
requestsdependency.requestsis imported (output_report.py,cveproject.py) but absent fromrequirements.txt; it works only transitively. Addrequestsexplicitly to avoid a break ifboto3/google-cloud-storagedrop it. - Deprecated Kubernetes API.
cron_job.yamlusesbatch/v1beta1, removed in Kubernetes 1.25+. Migrate tobatch/v1. - No test suite. The clean interfaces (
ReportBuilder,VulnDescriptionProvider, pureFlanXmlParser) are ideal for unit tests; none exist. Highest-value contribution. - K8s manifests lack capability hardening. The
Makefiledrops capabilities, butdeployment.yaml/cron_job.yamldo not. Add asecurityContextmirroring--cap-drop=all --cap-add=NET_RAW. - Silent upload failures.
aws_push.pyswallows exceptions; consider a non-zero exit or explicit logging on upload failure. - Serial scanning.
run.shscans targets sequentially; large ranges would benefit from parallelism or sharding. - Pinned, aging dependencies.
boto3 1.12.15,google-cloud-storage 1.23.0,Jinja2 2.11.3are old; review for security updates.
13. Quick Reference
13.1 Command Reference
| Command | Underlying action |
|---|---|
make build |
docker build --no-cache -t flan_scan -f Dockerfile . |
make start |
docker run --rm --cap-drop=all --cap-add=NET_RAW ... flan_scan (tex) |
make md |
as above with -e format=md |
make html |
as above with -e format=html |
make json |
as above with -e format=json |
docker run ... flan_scan <nmap-flags> |
Custom Nmap flags appended to the built-in command. |
13.2 Key Paths
| Path | Purpose |
|---|---|
/shared/ips.txt |
Target list (trailing newline required). |
/shared/reports/report_<ts>.<ext> |
Generated report. |
/shared/xml_files/<ts>/<ip>.xml |
Raw Nmap XML. |
/var/secrets/google |
Mounted cloud credentials (Kubernetes). |
contrib/report_builders/templates/jinja2_report.html |
HTML report template. |
13.3 Config Keys
| Key | Default | Values |
|---|---|---|
format |
tex |
tex, md, html, json |
upload |
(unset) | aws, gcp |
bucket |
(none) | S3/GCS bucket name |
13.4 Built-in Nmap command
nmap -sV -oX <xml-out> -oN - -v1 <passthrough-flags> --script=vulners/vulners.nse <ip>
13.5 Severity thresholds
| CVSS | Label |
|---|---|
< 4.0 |
Low |
4.0–6.9 |
Medium |
≥ 7.0 |
High |