Vulnsy Docs

Template Tags

Complete reference for Vulnsy DOCX template tags — fields, filters, conditionals, loops, and the version-agnostic CVSS object for report exports.

Template tags are placeholders in your Word template that get replaced with real data on export.

Vulnsy uses a small, composable tag grammar: a handful of fields, a set of filters that transform them (| date, | sort, | where, …), and one block syntax ({#…} … {/}) that serves as both loop and conditional. You don't memorise hundreds of tags — you compose a few.

Tag Syntax

FormMeaningExample
{field}Plain-text value{report.title}
{~field}Rich HTML → Word formatting (bold, lists, links, tables){~description}
{field | filter:"arg"}Transform a value with a filter{report.startDate | date:"UK"}
{#expr} … {/}Loop over a collection or a conditional block{#findings} … {/}
{%image}Image (base64 data, inside an evidence loop){%image}
{@field}Raw Word XML (advanced/pre-formatted){@captionXml}

Loop/conditional tags ({#…}, {/}) and image tags ({%image}) must each sit in their own paragraph — not inline with other text.

Plain vs. rich. {field} renders a value as plain text; {~field} renders an HTML field (description, remediation, narrative sections) with Word formatting. To force an HTML field to plain text — e.g. inside a table cell — pipe it through | text: {description | text}.

Close any block with a bare {/} (it closes the nearest open block), or name it explicitly with {/findings}.

Filters

Filters transform a value. Write them after a pipe, and chain as many as you like — they apply left to right.

{report.startDate | date:"UK"}
{#findings | where:"severity":"critical" | sort:"cvss"} … {/}
{severity | title}
FilterPurposeExample
date:"fmt"Format a raw date{report.startDate | date:"US_abbr"}
sort:"order"Order a findings collection{#findings | sort:"cvss"}
where:"field":"value"Keep only matching items{#findings | where:"severity":"high"}
countNumber of items in a collection{findings | where:"severity":"critical" | count}
join:"sep"Join a collection into text (default separator ", "){affectedAssets | join:", "}
default:"text"Fallback when a value is empty{report.overallRisk | default:"Not assessed"}
textStrip HTML to plain text{description | text}
upper / lower / titleChange letter case{severity | title} → "Critical"

Filter arguments:

  • date formats: long (default — "17th March 2025"), US, US_short, US_abbr, UK, UK_abbr, month, monthYear, iso.
  • sort orders: severity, cvss (3.1), cvss3, cvss4 (4.0), auto (the report's configured order).

count returns a number, and 0 is falsy — so it doubles as a conditional: {#findings | where:"severity":"critical" | count}There are critical findings.{/}.

Conditionals

A {#…} … {/} block renders its content only when the expression is truthy. Inside, you can compare real fields — there are no separate boolean tags.

{#report.overallRisk == "critical"}
This assessment identified critical risk.
{/}

{#cvss.score >= 7.0}High severity by CVSS.{/}

{#severity == "high" || severity == "critical"}
Priority remediation required.
{/}

Permitted operators: == != > < >= <= && || !. A bare {#cvss.temporal} … {/} is a presence check — it renders only when that data exists.

Compare against the stored value, not the display label. Severity values are critical, high, medium, low, info (lowercase) — {#severity == "Informational"} never matches; use {#severity == "info"}.

Report Fields

TagDescription
{report.title}Report title
{report.version}Version number (defaults to "1.0")
{report.status}Status: draft, review, or final
{report.overallRisk}Overall risk: critical, high, medium, low, informational, or Not Set
{report.sortOrder}Finding sort order: severity, cvss, or cvss4
{~report.executiveSummary}Executive summary (HTML → Word). Plain text: {report.executiveSummary | text}
{~report.methodology}Methodology (HTML → Word). Plain text: {report.methodology | text}
{report.startDate}Assessment start date (raw — reformat with | date)
{report.endDate}Assessment end date (raw — reformat with | date)

Conditional on risk: {#report.overallRisk == "critical"} … {/}. Conditional on sort order: {#report.sortOrder == "cvss"} … {/}.

Assessment dates resolve to the report's override if set, otherwise the parent project's dates.

Narrative & Scope Sections

Every narrative and scope section you write becomes a slug under report.sections (lowercased, non-alphanumeric → underscore). Keeping them under .sections means a section titled "Findings" can never collide with report.findings.

A section titled "Introduction"{~report.sections.introduction}. A section titled "Technical Summary"{~report.sections.technical_summary}.

Use {~report.sections.<slug>} for formatted HTML, or {report.sections.<slug> | text} for plain text.

Client Fields

TagDescription
{client.name}Company name
{client.contactName}Contact person
{client.contactEmail}Contact email
{client.contactPhone}Contact phone
{client.address}Address

Custom fields (key/value pairs you add on the client) are exposed as a loop — add a field in the app and it appears in the report with no template change:

{#client.customFields}{label}: {value}
{/}

Project Fields

TagDescription
{project.name}Project name
{project.description}Description
{project.type}Type (web_app, infrastructure, mobile, cloud, api, iot)
{project.status}Status
{project.startDate}Start date (raw — reformat with | date)
{project.endDate}End date (raw — reformat with | date)

project.* dates are always the literal project dates. Use report.startDate / report.endDate when you want the assessment-specific values (which honour a per-report override).

Custom fields: {#project.customFields}{label}: {value}{/}.

Dates

Dates are raw values. A bare tag renders the long format; the date filter reformats it:

TagOutput
{report.startDate}17th March 2025 (long, the default)
{report.startDate | date:"US"}03/17/2025
{report.startDate | date:"UK"}17/03/2025
{report.startDate | date:"US_abbr"}Mar/17/2025
{report.startDate | date:"month"}March
{report.startDate | date:"monthYear"}March 2025
{report.startDate | date:"iso"}2025-03-17

The same works for project.startDate, project.endDate, and metadata.generatedDate.

Summary Counts

Severity tallies for exec-summary tables. Each is a number.

TagDescription
{summary.critical}Count of critical findings
{summary.high}Count of high findings
{summary.medium}Count of medium findings
{summary.low}Count of low findings
{summary.info}Count of informational findings
{summary.total}Total finding count

These are the same numbers as {findings | where:"severity":"critical" | count}, kept as a convenience because nearly every report has a severity table.

Organization & Metadata

TagDescription
{organization.name}Organization name
{metadata.generatedDate}Generation date (raw — reformat with | date)
{metadata.generatedBy}Name of the user who exported

Consultants

Loop with {#consultants} … {/}:

TagDescription
{name}Consultant name (falls back to email)
{email}Email address
{jobTitle}Job title
{separator}, after every consultant except the last

Outside the loop, {consultantNames} gives all names pre-joined with commas — handy for a single table cell.

Findings

Loop with {#findings} … {/}. Each finding exposes:

Identification & Body

TagDescription
{order}Sequential number after sorting (1, 2, 3…)
{findingId}Formatted reference ID, e.g. REF-C1, REF-H2
{title}Finding title
{severity}critical, high, medium, low, info (e.g. {severity | title})
{status}open, in_progress, remediated, accepted_risk
{~description}Description (HTML → Word, with embedded pasted images)
{~remediation}Remediation (HTML → Word)
{~evidenceNarrative}Evidence narrative (HTML → Word)
{~references}References (HTML → Word)

For plain text of any HTML field, pipe through | text — e.g. {description | text}.

CVSS

CVSS is one version-agnostic object. cvss.* reflects whatever version the finding is scored with; {cvss.version} tells you which ("3.1" or "4.0"). Write your template against cvss.* and it works for both — no version branching.

TagDescription
{cvss.version}3.1 or 4.0
{cvss.score}Effective overall score (e.g. "7.5")
{cvss.vector}Full vector string
{cvss.severity}Severity label ("Critical", "High"…)
{cvss.base.score} / {cvss.base.severity} / {cvss.base.calc}Base metrics (.calc is a readable breakdown, 3.1)
{cvss.temporal.score} / .severity / .calcTemporal metrics (3.1)
{cvss.threat.score} / .severityThreat metrics (4.0)
{cvss.environmental.score} / .severity / .calcEnvironmental metrics

Presence conditionals (each renders only when that data exists — 0/absent is falsy):

{#cvss.present}CVSS v{cvss.version}: {cvss.score} ({cvss.severity}){/}
{#cvss.hasTemporal}Temporal: {cvss.temporal.score}{/}
{#cvss.hasThreat}Threat: {cvss.threat.score}{/}
{#cvss.hasEnvironmental}Environmental: {cvss.environmental.score}{/}

Showing both versions side by side — the explicit cvss.v3_1 and cvss.v4_0 objects (each present only if the finding carries that version):

{#cvss.v3_1}CVSS 3.1: {cvss.v3_1.score} — {cvss.v3_1.vector}{/}
{#cvss.v4_0}CVSS 4.0: {cvss.v4_0.score} — {cvss.v4_0.vector}{/}

Each has the same nested shape (.score, .vector, .severity, .base.*, etc.).

Evidence (nested loop)

{#evidence} … {/}:

TagDescription
{fileName}File name
{description}Description / caption
{%image}The image (base64 data)
{figureNumber}Sequential figure number across the document
{caption}"Figure N - description"
{@captionXml}Pre-formatted Word caption XML (Caption style + SEQ field)

Affected Assets (nested loop)

{#affectedAssets} … {/}:

TagDescription
{type}url, ip, hostname, network, other
{value}The asset value
{description}Asset description

For a single cell, join them instead of looping: {affectedAssets | join:", "} (or {affectedAssets | join:"\n"} for one per line).

Custom Fields (nested loop)

Per-finding key/value fields: {#customFields}{title}: {content}{/}.

Ordering & Filtering Findings

Instead of separate pre-sorted arrays, apply sort and where to the findings collection:

{#findings | sort:"cvss"} … {/}                          ← highest CVSS first
{#findings | where:"severity":"critical"} … {/}          ← critical only
{#findings | where:"severity":"high" | sort:"cvss"} … {/}  ← high, CVSS-ordered

Count matches (also works as a conditional): {findings | where:"severity":"critical" | count}.

Tabs

A report can have multiple tabs (e.g. "Web Application", "Infrastructure"). Loop every tab with {#tabs} … {/}. Inside the loop:

TagDescription
{name}Tab name
{exportTagPrefix}The tab's prefix, e.g. "appsec"
{overallRisk}Tab's overall risk — conditional: {#overallRisk == "critical"} … {/}
{sortOrder}Tab's finding sort order
{findingCount}Number of findings in this tab (use as a conditional to skip empty tabs)
{~sections.<slug>}This tab's narrative sections by slug
{#findings} … {/}Findings scoped to this tab (supports | sort / | where)
{#tabs}
{name} — {findingCount} findings

{#findings | sort:"severity"}
{findingId}. {title} ({severity | title})
{~description}
{/}
{/tabs}

Prefix-Scoped Tags (no loop)

When a tab has a custom exportTagPrefix (e.g. appsec), its data is also reachable at the top level under that prefix — useful for fixed-layout templates that don't loop tabs:

TagDescription
{#appsec.findings} … {/}That tab's findings (supports | sort / | where)
{appsec.findingCount}That tab's finding count
{appsec.sortOrder}That tab's sort order
{appsec.overallRisk}That tab's overall risk
{~appsec.sections.<slug>}That tab's narrative section by slug

Replace appsec with any custom prefix. When the prefix is report (the default), these live under report.*.

Template Patterns

Simple Report

{report.title}
Client: {client.name}
Date: {report.startDate | date:"UK"} — {report.endDate | date:"UK"}

{~report.executiveSummary}

{#findings | sort:"severity"}
{findingId} - {title} ({severity | title})
CVSS: {cvss.score} ({cvss.severity})
{~description}
{~remediation}
{#evidence}
{%image}
{@captionXml}
{/evidence}
{/findings}

Severity Sections (via where)

{#tabs}
{#findingCount}
{name} Technical Findings

{#findings | where:"severity":"critical"}
{title} — {cvss.score}
{~description}
{~remediation}
{/}

{#findings | where:"severity":"high"}
{title} — {cvss.score}
{~description}
{/}
{/findingCount}
{/tabs}

Wrap tab content in {#findingCount} … {/} to skip tabs with no findings, and put page breaks inside the severity blocks so they're skipped when empty.

Consultants Table

ConsultantEmailRole
{#consultants}{name}{email}{jobTitle}{/consultants}

Or a single string: {consultantNames}.

Moving From the Old Tags

If you have a template written for Vulnsy's earlier tag set, here's what changed. The old tags are no longer provided — update to the replacements below.

Old tagReplacement
{report.executiveSummary_plain}, any {field_plain}{report.executiveSummary | text}, {field | text}
{report.introduction}, {~report.introduction}{~report.sections.introduction}
{project.startDateUK}, {…startMonthYear}, all date variants{project.startDate | date:"UK"}, {… | date:"monthYear"}
{cvssScore}, {cvssVector}, {cvssBaseScore}, {cvssBaseCalc}{cvss.score}, {cvss.vector}, {cvss.base.score}, {cvss.base.calc}
{cvss3Score} / {cvss4Score}{cvss.v3_1.score} / {cvss.v4_0.score}
{#hasCvss}, {#hasTemporalScore}{#cvss.present}, {#cvss.hasTemporal}
{#findingsBySeverity}, {#findingsByCvss}{#findings | sort:"severity"}, {#findings | sort:"cvss"}
{#criticalFindings}, {#highFindings}, …{#findings | where:"severity":"critical"}, {… "high"}, …
{#report.isSortedByCvss}, tab {#isCritical}{#report.sortOrder == "cvss"}, {#overallRisk == "critical"}
{affectedAssets_text}{affectedAssets | join:", "}
{#allTabs}, {#appsec_tabs}{#tabs}
{#appsec.criticalFindings}{#appsec.findings | where:"severity":"critical"}

On this page