# Writing Report Templates ## Templating System VulnScout uses [Jinja2](https://jinja.palletsprojects.com/) as its templating engine, which has great community support, documentation, and supports any text-based format (AsciiDoc, HTML, Markdown, CSV, plain text, etc.). ### Template Locations | Path | Purpose | |------|---------| | `src/views/templates/` | Built-in templates shipped with VulnScout. | | `.vulnscout/templates/` | Per-project host-side templates (mounted automatically if the directory exists). | ### Adding a Custom Template Pass the file path directly to `--report` — it stages and runs the template in one step: ```bash ./vulnscout --project demo --report /path/to/my-report.adoc ``` ### Automatic Generation Set the `GENERATE_DOCUMENTS` environment variable to automatically build templates at the end of every scan. Multiple templates are comma-separated (values are trimmed): ```bash ./vulnscout config GENERATE_DOCUMENTS "summary.adoc, all_assessments.adoc" ``` Generated files are written to the outputs directory (default: `.vulnscout/outputs/`). All templates can also be run on-demand from the web interface using the export button in the toolbar. --- ### Global Variables Available in Templates | Variable | Type | Description | |----------|------|-------------| | `vulnerabilities` | dict | All vulnerabilities found in the project, keyed by ID. Use `vulnerabilities \| as_list` to get a list. | | `packages` | dict | All packages found in the project, keyed by ID. Use `packages \| as_list` to get a list. | | `assessments` | dict | All assessments found in the project, keyed by ID. Use `assessments \| as_list` to get a list. | | `author` | string | Company name producing this document (from `AUTHOR_NAME` config). | | `export_date` | string | Date of export as `YYYY-MM-DD`. | | `scan_date` | string | Date and time of the last scan, or `unknown date` if unavailable. | | `client_name` | string | Customer company name. Set via `CLIENT_NAME` config or overridden per-export from the web UI. May be empty. | | `unfiltered_vulnerabilities` | dict | All vulnerabilities, bypassing any active export filter. Use `\| as_list` to get a list. Always the full dataset. | | `unfiltered_assessments` | dict | All assessments, bypassing any active export filter. Keyed by assessment ID. | | `failed_vulns` | list[string] | List of vulnerability IDs that triggered the `--match-condition` expression. Empty when no condition was set or no vulnerability matched. Use `unfiltered_vulnerabilities[vuln_id]` to get the full object. | | `projects` | dict | All projects, keyed by UUID. Use `projects \| as_list` to get a list. Each value has the fields described in the Project Object section below. | | `variants` | dict | All variants, keyed by UUID. Use `variants \| as_list` to get a list. Each value has the fields described in the Variant Object section below. | | `scans` | dict | All scans, keyed by UUID. Use `scans \| as_list` to get a list. Each value has the fields described in the Scan Object section below. | | `sbom_documents` | dict | All SBOM documents, keyed by UUID. Use `sbom_documents \| as_list` to get a list. Each value has the fields described in the SBOM Document Object section below. | --- ### Environment Variables Available in Templates Use `env(name, default="")` to access host environment variables in your templates. For example, to fetch the value of the `DISTRO` environment variable: ``` {{ env("DISTRO") }} {{ env("DISTRO", "unknown") }} ``` VulnScout will automatically scan your templates for `env("...")` patterns and pass the corresponding environment variable values from the host system. When exporting, users can add filters to export only some vulnerabilities. To bypass these filters and access all vulnerabilities, use `unfiltered_vulnerabilities` instead of `vulnerabilities` and `unfiltered_assessments` instead of `assessments`. This is useful when producing a summary or showing the number of filtered-out vulnerabilities. --- ### Vulnerability Object | Field | Type | Description | |-------|------|-------------| | `id` | string | CVE / vulnerability ID. | | `found_by` | list[string] | Tools that found the vulnerability (e.g. `grype`, `yocto`, `spdx3`). | | `datasource` | string | Primary URL for the vulnerability. | | `namespace` | string | Database name (e.g. `nvd`). | | `aliases` | list[string] | Alternative IDs for this vulnerability. | | `related_vulnerabilities` | list[string] | IDs of related vulnerabilities. | | `urls` | list[string] | Additional reference URLs. | | `texts` | dict[str, str] | Descriptions and notes. Key is the title, value is the content. | | `severity.severity` | string | Severity level: `low`, `medium`, `high`, `critical`, `unknown`. | | `severity.min_score` | float | Minimum CVSS score (0–10). | | `severity.max_score` | float | Maximum CVSS score (0–10). | | `severity.cvss` | list[CVSS] | List of CVSS scoring objects (version, score, vector, author). | | `epss.score` | float | EPSS probability score (0–1). | | `epss.percentile` | float | EPSS rank relative to all CVEs (0–1). | | `effort.optimistic` | string | Optimistic fix time estimate in ISO 8601 duration (e.g. `PT5H`). | | `effort.likely` | string | Likely fix time estimate in ISO 8601 duration (e.g. `P1D`). | | `effort.pessimistic` | string | Pessimistic fix time estimate in ISO 8601 duration (e.g. `P2DT4H`). | | `packages` | list[string] | Packages affected by the vulnerability, in form `name@version`. | | `published` | string | CVE publish date as ISO 8601, or empty if unknown. | | `status` | string | Current status string (see Assessment object for possible values). | | `simplified_status` | string | Simplified status: `pending`, `affected`, `fixed`, or `ignored`. | | `last_assessment` | Assessment | Most recent assessment object (shorthand for `assessments[0]`). | | `assessments` | list[Assessment] | All assessments, sorted most recent first. | | `variant_ids` | list[string] | UUIDs of every variant in which this vulnerability has at least one assessment. | | `assessments_by_variant` | dict[string, list[Assessment]] | Assessments grouped by variant ID. Key is the variant UUID, value is the list of assessments for that variant. | --- ### Package Object | Field | Type | Description | |-------|------|-------------| | `name` | string | Name of the package. | | `version` | string | Version of the package. | | `cpe` | list[string] | List of CPE identifiers. | | `purl` | list[string] | List of PURL identifiers. | | `sbom_documents` | dict[string, SBOM Document] | SBOM documents that list this package, keyed by UUID. | | `variants` | list[string] | UUIDs of variants in which this package appears (derived from its SBOM documents). | | `vulnerabilities` | dict[string, Vulnerability] | Vulnerabilities affecting this package, keyed by vulnerability ID. | --- ### Assessment Object | Field | Type | Description | |-------|------|-------------| | `id` | string | Assessment ID (UUID). | | `vuln_id` | string | ID of the vulnerability this assessment affects. | | `packages` | list[string] | List of packages concerned. | | `timestamp` | string | Creation datetime in ISO 8601 format. | | `last_update` | string | Last update datetime in ISO 8601 format. | | `status` | string | Status of the assessment (see possible values below). | | `simplified_status` | string | Simplified status: `pending`, `affected`, `fixed`, or `ignored`. | | `status_notes` | string | Free-text note about the status. | | `justification` | string | Justification for the status (see possible values below). | | `impact_statement` | string | Text explaining why the vulnerability does not apply. | | `responses` | list[string] | List of remediation responses (see possible values below). | | `workaround` | string | Workaround description. | | `workaround_timestamp` | string | Datetime of this workaround in ISO 8601 format. | | `variant_id` | string | UUID of the variant this assessment belongs to, or empty. | --- ### Project Object | Field | Type | Description | |-------|------|-------------| | `id` | string | Project ID (UUID). | | `name` | string | Name of the project. | --- ### Variant Object | Field | Type | Description | |-------|------|-------------| | `id` | string | Variant ID (UUID). | | `name` | string | Name of the variant (e.g. build configuration). | | `project_id` | string | UUID of the project this variant belongs to. | | `scans` | dict[string, Scan] | All scans belonging to this variant, keyed by UUID. | | `sbom_documents` | dict[string, SBOM Document] | All SBOM documents belonging to this variant (across all its scans), keyed by UUID. | | `packages` | dict[string, Package] | All packages found in this variant, keyed by `name@version`. | | `assessments` | list[Assessment] | All assessments scoped to this variant. | | `vulnerabilities` | dict[string, Vulnerability] | All vulnerabilities that have at least one assessment in this variant, keyed by vulnerability ID. | --- ### Scan Object | Field | Type | Description | |-------|------|-------------| | `id` | string | Scan ID (UUID). | | `description` | string | Description of the scan (may be empty). | | `timestamp` | string | Scan datetime in ISO 8601 format. | | `variant_id` | string | UUID of the variant this scan belongs to. | | `variant` | Variant | The variant object this scan belongs to. | | `sbom_documents` | dict[string, SBOM Document] | All SBOM documents produced by this scan, keyed by UUID. | | `packages` | dict[string, Package] | All packages found across this scan's SBOM documents, keyed by `name@version`. | --- ### SBOM Document Object | Field | Type | Description | |-------|------|-------------| | `id` | string | SBOM document ID (UUID). | | `path` | string | File path of the SBOM document. | | `source_name` | string | Name of the source that produced this document. | | `format` | string | Format of the document (e.g. `spdx`, `cdx`, `openvex`, `yocto_cve_check`). | | `scan_id` | string | UUID of the scan this document belongs to. | | `variant_id` | string | UUID of the variant this document belongs to (derived from its scan). | | `scan` | Scan | The scan object this document belongs to. | | `packages` | dict[string, Package] | All packages listed in this document, keyed by `name@version`. | | `vulnerabilities` | dict[string, Vulnerability] | All vulnerabilities affecting any package in this document, keyed by vulnerability ID. | #### Possible values for `status` | Value | Description | Filter | |-------|-------------|--------| | `in_triage`, `under_investigation` | Vulnerability found but presence not confirmed. Default status. | `status_pending`, `status_active` | | `affected`, `exploitable` | Vulnerability confirmed and affecting the product. | `status_affected`, `status_active` | | `fixed`, `resolved`, `resolved_with_pedigree` | Vulnerability is fixed and no longer exploitable. | `status_fixed`, `status_inactive` | | `not_affected`, `false_positive` | Vulnerability is a false positive or not affecting us. | `status_ignored`, `status_inactive` | #### Possible values for `justification` - `component_not_present` - `vulnerable_code_not_present` - `vulnerable_code_not_in_execute_path` - `vulnerable_code_cannot_be_controlled_by_adversary` - `inline_mitigations_already_exist` - `code_not_present` - `code_not_reachable` - `requires_configuration` - `requires_dependency` - `requires_environment` - `protected_by_compiler` - `protected_at_runtime` - `protected_at_perimeter` - `protected_by_mitigating_control` #### Possible values for `responses` - `can_not_fix` - `will_not_fix` - `update` - `rollback` - `workaround_available` --- ## Filters and Helpers In addition to [Jinja built-in filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-builtin-filters), the following custom filters are available. ### Conversion | Filter | Description | |--------|-------------| | `as_list` | Convert a dict to a list using `.values()`. | | `limit(n)` | Limit the number of results to `n` (int). | | `print_iso8601` | Transform an ISO 8601 duration string into a human-readable format (e.g. `P2DT4H` → `2d 4h`) or format a datetime string. | ### Status Filtering | Filter | Description | |--------|-------------| | `status(x)` | Keep only vulnerabilities with status in `x` (str or list of str). | | `status_pending` | Shorthand for `in_triage` + `under_investigation`. | | `status_affected` | Shorthand for `affected` + `exploitable`. | | `status_fixed` | Shorthand for `fixed` + `resolved` + `resolved_with_pedigree`. | | `status_ignored` | Shorthand for `not_affected` + `false_positive`. | | `status_active` | `status_pending` + `status_affected`. | | `status_inactive` | `status_fixed` + `status_ignored`. | ### Score Filtering | Filter | Description | |--------|-------------| | `severity(x)` | Keep only vulnerabilities with severity in `x` (str or list of str). | | `epss_score(x)` | Keep only vulnerabilities with EPSS score ≥ `x`. `x` is a percentage in [0, 100]. | ### Date Filtering Both `last_assessment_date` and `filter_by_publish_date` accept date expressions in the following formats: | Format | Meaning | |--------|---------| | `'>2026-01-01'` | After this date (exclusive) | | `'>=2026-01-01'` | After or on this date (inclusive) | | `'<2026-01-01'` | Before this date (exclusive) | | `'<=2026-01-01'` | Before or on this date (inclusive) | | `'2026-01-01..2026-01-31'` | Between two dates (inclusive) | | `'2026-01-01'` | Exact date match | | Filter | Description | |--------|-------------| | `last_assessment_date(x)` | Filter vulnerabilities by their last assessment date. | | `filter_by_publish_date(x, include_unknown=False)` | Filter by CVE publish date. Pass `include_unknown=True` to also include vulnerabilities with no known publish date. | > **Note for `filter_by_publish_date`:** Exact date match ignores time (hours, minutes, seconds). When `include_unknown=True`, also includes vulnerabilities without a published date. ### Sorting | Filter | Description | |--------|-------------| | `sort_by_epss` | Sort vulnerabilities by EPSS score, highest first. | | `sort_by_effort` | Sort vulnerabilities by effort (`effort.likely`), most effort first. | | `sort_by_last_modified` | Sort vulnerabilities by latest assessment date, most recent first. | | `sort_by_scan_date` | Sort scans by timestamp, most recent first. Accepts a dict (keyed by ID) or a list. | ### Relational Filtering | Filter | Description | |--------|-------------| | `filter_by_variant(variant_id)` | Keep only items that belong to the given variant. Works on assessments, scans, and vulnerabilities (matches against `variant_ids`). Accepts a dict or a list. | | `filter_by_project(project_id)` | Keep only items whose `project_id` matches. Works on variants. Accepts a dict or a list. | --- ## Common Examples and Tips **Get total count of active/open vulnerabilities:** ``` {{ vulnerabilities | as_list | status_active | length }} ``` **Get active vulnerability with highest EPSS score:** ``` {{ vulnerabilities | as_list | status_active | sort_by_epss | first }} ``` **Get vulnerabilities assessed after a specific date:** ``` {{ vulnerabilities | as_list | last_assessment_date('>2026-01-01') | length }} ``` **Get vulnerabilities assessed in January 2026:** ``` {{ vulnerabilities | as_list | last_assessment_date('2026-01-01..2026-01-31') }} ``` **Get critical vulnerabilities assessed recently (after or on 2026-01-15):** ``` {{ vulnerabilities | as_list | severity('critical') | last_assessment_date('>=2026-01-15') | sort_by_last_modified }} ``` **List all critical unresolved vulnerabilities, sorted by most recent assessment:** ``` {% for vuln in vulnerabilities | as_list | severity('critical') | status_active | sort_by_last_modified %} - {{ vuln.id }} (CVSS: {{ vuln.severity.max_score }}) {% endfor %} ``` **Use an environment variable in a template:** ``` Product: {{ env("PRODUCT_NAME", "unknown") }} Version: {{ env("PRODUCT_VERSION", "unknown") }} ``` **List all projects and their variants:** ``` {% for project in projects | as_list %} Project: {{ project.name }} {% for variant in variants | filter_by_project(project.id) %} - Variant: {{ variant.name }} {% endfor %} {% endfor %} ``` **List most recent scans for a variant:** ``` {% for scan in scans | filter_by_variant(variant_id) | sort_by_scan_date | limit(5) %} - {{ scan.timestamp | print_iso8601 }}: {{ scan.description }} {% endfor %} ``` **List SBOM documents:** ``` {% for doc in sbom_documents | as_list %} - {{ doc.source_name }} ({{ doc.format }}): {{ doc.path }} {% endfor %} ``` --- ## Traversing the data models Every entity exposes its related entities as embedded fields, so you can traverse the data model in any direction. The relationships are: ``` Project └─ Variant (.scans, .sbom_documents, .packages, .vulnerabilities, .assessments) └─ Scan (.sbom_documents, .packages, .variant) └─ SBOM Document (.packages, .vulnerabilities, .scan) └─ Package (.sbom_documents, .variants, .vulnerabilities) └─ Vulnerability (.variant_ids, .assessments_by_variant) ``` **Loop over vulnerabilities per variant:** ``` {% for variant in variants | as_list %} {{ variant.name }} {% for vuln in variant.vulnerabilities.values() | sort_by_epss %} - {{ vuln.id }} (EPSS: {{ vuln.epss.score }}) {% endfor %} {% endfor %} ``` **Loop over packages per SBOM document:** ``` {% for doc in sbom_documents | as_list %} {{ doc.source_name }} — {{ doc.path }} {% for pkg_id, pkg in doc.packages.items() %} - {{ pkg.name }} {{ pkg.version }} {% endfor %} {% endfor %} ``` **Full traversal:** ``` {% for project in projects | as_list %} {% for variant in variants | filter_by_project(project.id) %} {% for doc in variant.sbom_documents.values() %} {% for vuln in doc.vulnerabilities.values() | status_active | sort_by_epss %} {{ vuln.id }} in {{ doc.path }} {% endfor %} {% endfor %} {% endfor %} {% endfor %} ``` **Per-variant assessment breakdown for a single vulnerability:** ``` {% for variant_id, assessments in vuln.assessments_by_variant.items() %} Variant {{ variant_id }}: {{ assessments | first | attr('status') }} {% endfor %} ``` **Packages that appear in multiple variants:** ``` {% for pkg_id, pkg in packages.items() %} {% if pkg.variants | length > 1 %} {{ pkg.name }} {{ pkg.version }} appears in {{ pkg.variants | length }} variants {% endif %} {% endfor %} ``` **Most recent scan for a variant (using embedded `.scans`):** ``` {% for variant in variants | as_list %} {% set latest = variant.scans | sort_by_scan_date | first %} {{ variant.name }}: last scanned {{ latest.timestamp | print_iso8601 }} {% endfor %} ```