Encode a GitHub issue into a VERIS / VCDB incident
You turn a GitHub issue that describes a data breach into one VERIS-schema JSON
object and write it to data/json/submitted/<UUID>.json.
Skill version: 20260801T010623Z. This is the skill's revision timestamp
(UTC date + Zulu time, YYYYMMDDThhmmssZ). Claude skills have no automatic
version number, so this string is the version of record. Bump it whenever you
edit this skill to the current UTC date+time —
python3 -c "import datetime; print(datetime.datetime.now(datetime.UTC).strftime('%Y%m%dT%H%M%SZ'))"
— and embed it verbatim in plus.analyst_notes (see Step 4) so reviewers know
which skill version — and that AI — produced each record.
Inputs
- Required: a GitHub issue URL, e.g.
https://github.com/vz-risk/VCDB/issues/23372. - Optional: the analyst's GitHub handle for
plus.analyst. If it was not provided when the skill was invoked, ask the user for it once before writing the file (do not guess it).
Reference material — read these first
- Local schema:
vcdb-merged.json(repo root). This is the authoritative schema and enumeration list the output is validated against. Read it to learn the exact allowed enum values for every field before assigning any. - Local labels/enum helpers if useful:
vcdb-enum.json,vcdb-labels.json,vcdb-keynames.txt. - Online docs: https://verisframework.org/schema-docs.html for the meaning of fields and the 4 A's (Actor, Action, Asset, Attribute).
- A real example incident: any file under
data/json/validated/shows the expected shape (e.g.incident_id,action,actor,asset,attribute,discovery_method,timeline,victim,reference,summary,plus). - Fallback reference (use only when needed): the full VERIS Coding Style
Guide v3.0 is bundled (git-ignored) at
references/coding-style-guide-v3.0.txt(and.pdf) relative to this skill. The common coding rules are already distilled inline below, so do not read the whole guide on every run. Consult it only for a case type the inline rules don't cover (rare action varieties, unusual multi-actor or supply-chain situations) —grepthe.txtfor the action/topic rather than reading it end to end. - Templates are NOT in the guide PDF. The starter template JSON files live in
the
vz-risk/verisGitHub repo (https://github.com/vz-risk/veris). When a template-name tag is present, fetch the matching template from there, not from the bundled guide (which only describes templates in prose/diagrams).
Procedure
1. Gather the breach facts (be thorough — use multiple sources)
- Read the GitHub issue.
ghis not installed, so fetch the issue with WebFetch (the vz-risk/VCDB repo is public). Extract:- The title and full description of the breach.
- Labels / tags on the issue — these are strong signals. In particular a
NAICS code label maps directly to
victim.industry; action-category labels (Hacking, Malware, Misuse, Error, Physical, Social, Environmental) map to the correspondingaction.*block;BreachvsIncidentinformsattribute.confidentiality.data_disclosure. - Template-name labels are the strongest signal of all. Many VCDB issue
tags are coding template names, formatted
Action-actor-variety[-differentiator](e.g.Hacking-ext-unknown,Hacking-ext-stolencreds,Social-ext-Extortion,Social-ext-Phish,Malware-ext-Ransomware-databreach,Malware-ext-Ransomware-nobreach,Error-int-misconfig-srv,Misuse-int-privabuse,Physical-ext-theft-userdev,Social-Hacking-ext). Decode the tag directly:Hacking-ext-unknown→ external actor,action.hackingwithvariety: ["Unknown"];Malware-ext-Ransomware-databreach→ external actor, ransomware malware + confirmed confidentiality breach; etc. Starter template JSON files live in thevz-risk/verisrepo (https://github.com/vz-risk/veris) — you may fetch the matching template as a scaffold and then fill in case specifics. The template gives you Action + Actor + key varieties for free; you still supply victim demographics, assets, discovery method, attributes, and references. - IGNORE
PHIDBR####labels entirely. They are an artifact/noise tag the team applies to mark incidents that would go into a (largely dormant) medical-breach report. They are NOT a data signal: do not let them affectplus.sub_source, dates,plus.dbir_year, or any other field. In particular the embedded year (e.g. the2019inPHIDBR2019) is NOT the incident year. - Every URL / reference mentioned in the issue body and comments.
- Visit the linked URLs with WebFetch and read them for incident detail
(who, what, how, what data, how many records, when, victim org & size).
Some sources are PDFs, not HTML — state AG breach-notification filings
(e.g.
oag.ca.gov/...) are especially common.WebFetchfrequently cannot parse these (it may report the PDF as corrupted/malformed even when it renders fine in a normal reader). When a source URL is or links to a PDF, download it and extract text locally instead (e.g.curlthe URL, thenpdftotext -layout <file> -) rather than giving up on that source. Also note: many state AG notice PDFs are template letters with unfilled placeholder tokens (e.g.<<b2b_text_2 (Data Elements)>>) — a placeholder token is not a source statement that the field is empty/unknown; it just means this particular filing didn't fill in the data-elements list in the copy you're reading. Don't treat it as contradicting a more specific narrative-article source. - Find an additional independent source — but only when the issue needs it.
If the issue body/comments already contain multiple corroborating source
URLs, that's sufficient sourcing; searching for one more is optional (still
worth doing for a very recent development, but not required). If the issue
has one or zero sources, run a WebSearch for the victim organization +
"data breach" (plus year/specifics) and fetch at least one credible source
not already linked in the issue, to corroborate or fill gaps.
Always include every issue-linked URL in
reference, in addition to any new source(s) you find — do not let "go find a newer source" cause you to drop the URLs that were already in the issue body or comments. They go in the top-levelreferencefield as a single string with URLs separated by semicolons (;), which is the VCDB/WebApp convention.- Social media is NOT a source. Reddit, X/Twitter, Facebook, and LinkedIn
posts may point you to a real article, but never cite the social-media
post itself in
reference— follow it to the underlying article and cite that. Prefer established security press, vendor/IR advisories, official victim notices, and mainstream news; weightconfidenceaccordingly.
- Social media is NOT a source. Reddit, X/Twitter, Facebook, and LinkedIn
posts may point you to a real article, but never cite the social-media
post itself in
- Establish the real incident date from the sources BEFORE trusting any year
in a label or tag. A recent article can describe an old breach and vice
versa. Never flag a "conflict" between a label year and the breach until you
have read the sources and know the actual date.
timeline.incidentis the date the incident began — the earliest known compromise / initial-access date — not the disclosure or report date. When both are known and differ (e.g. access in March, disclosed in June), use the begin date fortimeline.incidentand capture the gap as a duration intimeline.compromise.timeline.incident.yearfollows this begin date.plus.dbir_yearis derived from it but is not the same number — see the DBIR-year rule in Step 4. - Look up the victim's employee count — do not leave it
Unknownby default. Run a WebSearch for the victim org (e.g."<org name> employees", LinkedIn, or a company-profile/Google Business result) and map the result to the nearestvictim.employee_countenum bucket invcdb-merged.json. Only fall back to"Unknown"when no source or lookup yields even an approximate headcount. - Attribution: a documented named actor stands unless a source contradicts
it. If one source names a group and only an earlier source says "no one
has claimed," treat the named attribution as current (note the discrepancy in
plus.analyst_notes). When an actor is named, research that actor (a quick WebSearch) to setactor.external.varietyandmotivecorrectly — e.g. a cyber-extortion / ransomware crew is typicallyvariety: ["Organized crime"],motive: ["Financial"]— and put the group inactor.external.name.
2. Map to the VERIS schema
Build the JSON object using vcdb-merged.json as the source of truth for
field names and allowed enum values (never invent an enum value — if a
concept isn't in the enum, use the closest Other/Unknown member and explain
in plus.analyst_notes). Cover at minimum the schema's required top-level keys:
incident_id, security_incident, summary, actor, action,
asset, attribute (when there was data disclosure), discovery_method,
timeline, and schema_version.
VERIS coding rules from the Coding Style Guide (apply these)
- Never speculate. Code only what the sources state; use
Unknownrather than guessing a value. The DBIR team's rule: "we try not to do that." - Trust specific attacker-claimed details over
Unknown. When the only source for a detail is the attacker (a leak-site post, ransom note, or interview) and the victim hasn't confirmed it, still prefer the attacker's specific value to a blankUnknown— e.g. codeaction.hacking.variety/vector(Use of stolen creds, etc.), the asset hosting, and the exfiltration scope from the attacker's claim. A specific value is more useful thanUnknown, and the record can be revised if a later source contradicts it. This is not "speculation" — it is coding what a source (the attacker) stated. Always note inplus.analyst_notesthat the detail is attacker-sourced and not victim-confirmed. (Distinct from attribution rule #5 below, which governs who the actor was.) - VERIS is actor-centric, not data-location-centric. Decide the actor by who
performed the breaching action relative to the victim org:
- Works for the victim org → internal (includes on-prem individual contractors treated as staff).
- Doesn't work for the victim, but works for a business/partner that the victim engaged → partner.
- Otherwise → external (even if the vector was a partner). A third party holding the victim's data being breached does NOT make it a partner-actor breach unless the actor worked for that partner.
- Actor orientation ≠ which org is the primary victim. Concluding the
actor is
externaldoes not make the org whose data sat there the primaryvictim. Actor orientation and whose perspective you code the incident from (thevictimblock) are independent decisions — check the provider-breach rule below before settingvictim.
- Do not invent an Error actor/action for "failure to implement a control" (e.g. "they didn't patch"). Only code overt actions that directly caused the breach. Multiple actors require actual collusion with intent.
- Motive may be inferred from the nature of the attack even when the actor is
unknown, but only when a concrete monetization signal is present — a
ransom/extortion demand, data listed for sale or auction, confirmed fraud
following the breach, or a named financially-motivated actor/group. Do not
infer
Financialfrom the mere fact that valuable data (PII, SSNs, financial records) was taken — theft of sensitive data alone is not evidence of why it was taken. When the actor is unknown and no monetization signal exists, codemotive: ["Unknown"]. A process-driven, repeat-MO crew (ransomware/extortion gangs) ⇒variety: ["Organized crime"](this does NOT mean the mafia). Errors and force-majeure ⇒motive: ["NA"]. data_disclosureis graded:"Yes"when confidentiality is confirmed compromised — this includes not just directly-observed viewing/copying but also confirmed unauthorized access to a live system/account that holds the data (e.g. a compromised login, an attacker with confirmed access to a server, mailbox, or database) or confirmed credential compromise used to reach data — access to the system is itself sufficient to code"Yes", don't hold out for explicit proof the attacker opened/exfiltrated specific files. Reserve"Potentially"/ at-risk for cases where access itself is unconfirmed or the exposure is passive — e.g. a stolen but only password-protected (unencrypted) device with no evidence it was ever unlocked, or ransomware with no evidence of access to data beyond encryption. Paper documents lost/stolen/misdelivered are treated as confirmed (someone must read them to identify them). Encrypted-and-no- passphrase device ⇒"No".- Discovery method for actor-publicized incidents =
external→["Actor disclosure"]— ransomware ransom notes, extortion demands, leak-site posts, or any case the attacker announced. Useinternalonly when the victim's own controls/people detected it,partnerwhen an outsourced provider did, andunknownonly when truly unstated. - Assets are hierarchical: selecting a child implies its parent (e.g.
U - Desktop or laptopis the parent ofU - Laptop); when sources don't distinguish, code the parent. People are assets in social attacks (P - ...). - Third-party / supply-chain / provider breaches — code from the provider's
perspective. When the breached entity is a shared service provider
(MSP/MSSP, IT contractor, hosting/SaaS vendor, billing or processing firm) and
the compromise reaches its customers — especially when more than one customer
org is hit, or a shared credential/tool/system common to its clientele was the
point of compromise — code ONE incident with the provider as the
primary
victim, not the customer:victim.victim_id,victim.industry,victim.government,victim.employee_count, andvictim.stateall describe the provider (e.g. NAICS541512Computer Systems Design, not the customer's NAICS).- List every affected customer org in
victim.secondary.victim_idas"OrgName; NAICS"strings. - The attacker who breached the provider is still
actor.external(the actor-centric rule stands);action.*.vector: ["Partner"]captures that the impact propagated through the provider relationship. Do not create one incident per customer (it inflates counts). - Recognition signals (these outweigh which org the news story centers on): a provider's credentials/tooling described as reused across its clientele; multiple client orgs affected; a lawsuit or breach notice naming the provider as the failing party. When you see these, the provider is the primary victim even if the article is framed around one angry customer.
source_id="vcdb"(lowercase) for these incidents — match the corpus (the validated files andvcdb-merged.jsonuse lowercase;source_idhas no enum constraint, so case is not auto-corrected).security_incident:"Confirmed"for a real incident,"Suspected"if unsure, plus"Near Miss"/"False Positive"as applicable.
Guidance:
incident_idandplus.master_id: a newly generated uppercase UUID (python3 -c "import uuid; print(str(uuid.uuid4()).upper())"). This is also the filename.security_incident: usually"Confirmed".summary: a concise prose summary of the breach.victim.industry: the NAICS code from the issue label when present; also setvictim.country,victim.victim_id(org name) when known, andvictim.employee_countper the lookup in Step 1 ("Look up the victim's employee count") — do not skip the search just because the issue/sources don't state it outright.action/actor/asset/attribute: populate from the sources.actor.<orientation>.country: set it (an array of ISO country codes) when the sources reveal the actor's location; otherwise code["Unknown"].action.<category>.result: tag the outcome(s) each action achieved, from the enumInfiltrate,Exfiltrate,Elevate,Lateral movement,Deploy payload,Persist(Other/Unknown/NAas needed). Common cases: an intrusion that achieved entry →["Infiltrate"]; data theft → add"Exfiltrate"to the action that stole it (e.g.action.hacking.result: ["Infiltrate", "Exfiltrate"]). This mirrors theevent_chainarc — keep the two consistent. Omitresultfor an action whose outcome is unclear rather than guessing.timeline.incident.year(and month/day if known) = the date the incident began (initial access / earliest compromise), not the disclosure date; this is the basis forplus.dbir_year(derived per the DBIR-year rule below). Record the access-to-discovery gap intimeline.compromise(a duration). Gotcha: onlytimeline.incidentis a date (year/month/day).timeline.discovery,compromise,exfiltration, andcontainmentare duration objects{unit, value}(unit∈ Seconds…Years / Never / NA / Unknown) — never put a year/month there.- Extortion: code
action.social.varietyto include"Extortion"whenever the sources document an actual ransom / extortion demand (a threat to sell, publish, or leak data, or a ransom figure) — regardless of whether malware/ransomware was involved. Do not add Extortion merely because ransomware is present (corpus analysis shows the team codes Extortion on only ~2% of ransomware incidents but does use it for hacking/data-theft extortion)."Extortion"lives only underaction.social.variety. Whenever you set anyaction.socialblock (Extortion, Phishing, Pretexting, etc.) you MUST also do all three of the following — they are enforced by the semantic lint in Step 5:- (a) include
targetandvectorarrays — use["Unknown"]if the sources don't say who was targeted or how the demand was delivered; - (b) add an
attribute.integrityblock whosevarietyincludes"Alter behavior". A social action is by definition an attempt to influence or alter human behavior, so it always carries this integrity impact. Code it even when the attempt failed (e.g. a ransom demand that was refused) — note the refusal inplus.analyst_notesrather than dropping the attribute; - (c) add a People asset to
asset.assets: a"P - …"variety for the targeted person, using"P - Unknown"if the role is unspecified.
- (a) include
- Data scope: code the full claimed scope of exfiltrated data in
attribute.confidentiality.data[].variety, not just one category — e.g.Medical,Personal,Source code,Secrets(trade secrets / unreleased R&D) as applicable. confidentiality.state: whenever you codeconfidentiality.data, setattribute.confidentiality.state(an array) — the storage/transmission state of the breached data. Default to"Stored"for an at-rest breach when the sources don't state the encryption status — do not assume"Stored unencrypted". Use"Stored unencrypted"/"Stored encrypted"only when a source actually indicates the encryption status,"Printed"for paper documents,"Transmitted unencrypted"/"Transmitted encrypted"for data intercepted in transit, and"Unknown"only when the state is truly unstated.data_total: only set it when you have a defensible total across all victim groups. If only a partial count is known (e.g. patients but not employees), omitdata_totaland explain inplus.analyst_notesrather than logging a misleadingly low number.
Cross-field consequences of specific actions/varieties (enforced by the lint in Step 5)
Certain codings imply others must also be present — set both, don't stop at
the trigger value. These are ported from the upstream VERIS rules.py
enrichment conventions, adapted to this repo's current vcdb-merged.json
field names where they've drifted from what rules.py assumes (noted below).
The value_chain-based rules in rules.py (Phishing/C2/Ransomware/Email →
value_chain.*) are intentionally not ported, since value_chain is
deprecated for VCDB coding (see Step 5, check 5).
- Ransomware → an availability impact covering both encryption and outage.
Whenever
action.malware.varietyincludes"Ransomware", codeattribute.availability.varietyto include both"Obscuration"(the data was rendered inaccessible/encrypted) and"Interruption"(normal system/service operation was disrupted). Separately, per the generic malware rule below,attribute.integrity.varietyshould still include"Software installation"for the ransomware payload itself. - Malware → integrity "Software installation". Any
action.malwareblock impliesattribute.integrity.varietyincludes"Software installation". - SQLi → integrity "Repurpose".
action.hacking.varietyincluding"SQLi"impliesattribute.integrity.varietyincludes"Repurpose". - Physical theft or error-loss → availability "Loss".
action.physical.varietyincluding"Theft", oraction.error.varietyincluding"Loss", impliesattribute.availability.varietyincludes"Loss". - Web application hacking vector → an asset for it.
action.hacking.vectorincluding"Web application"impliesasset.assetsincludes a"S - Web application"entry. - DoS malware → a Secondary motive.
action.malware.varietyincluding"DoS"implies the acting party'smotive(whichever ofactor.external/actor.internal/actor.partneris populated) includes"Secondary", in addition to any other motive already coded. - Sum/max consistency:
impact.overall_amount, if set, must be at least the sum ofimpact.loss[].amount;attribute.confidentiality.data_total, if set, must be at least the max ofattribute.confidentiality.data[].amount;victim.secondary.amountmust be at least the number of entries invictim.secondary.victim_id.
Enumeration hierarchies — selecting a child value also requires its parent value (do not code only the most specific term):
| Field | Child(ren) | Parent to also include |
|---|---|---|
attribute.confidentiality.state |
Stored encrypted, Stored unencrypted |
Stored |
attribute.confidentiality.state |
Transmitted encrypted, Transmitted unencrypted |
Transmitted |
action.malware.variety |
Click fraud, Cryptocurrency mining |
Click fraud and cryptocurrency mining |
action.malware.variety |
Backdoor, C2 |
Backdoor or C2 |
action.malware.variety |
Backdoor + Trojan together |
RAT (and RAT implies both Backdoor and Trojan) |
action.malware.vector |
Web application - download, Web application - drive-by |
Web application |
action.malware.vector |
Email attachment, Email autoexecute, Email link, Email other, Email unknown |
Email |
action.hacking.variety |
any exploit-style variety (SQLi, XSS, Buffer overflow, CSRF, Path traversal, Insecure deserialization, etc.) |
Exploit vuln |
asset.assets[].variety |
U - Desktop, U - Laptop |
U - Desktop or laptop |
action.social.target |
End-user, Other employee |
End-user or employee |
attribute.confidentiality.data[].variety |
Medical, Sensitive Personal, Bank, Payment |
Personal |
attribute.confidentiality.data[].variety |
API key, Digital certificate, Multi-factor credential, Session key |
Credentials |
attribute.integrity.variety |
Register MFA device, Created account |
Modify authentication |
Note two adaptations from upstream rules.py: the Stored/Transmitted
hierarchy lives under attribute.confidentiality.state in this repo's schema,
not confidentiality.variety as rules.py assumes; and the Email vector
children live under action.malware.vector, not action.malware.variety.
rules.py's malware→Exploit vuln rule (triggered by Remote injection /
Web application - Web drive-by) is not ported — those enum values no longer
exist in this schema.
Building plus.event_chain (the attack story)
plus.event_chain is a schema-validated array that decomposes the incident
into ordered steps — the kill chain in sequence. Populate it whenever the
incident has more than one phase (almost all ransomware, hack-and-extort, and
multi-action incidents). A single-phase incident may have a one-event chain.
Each event is an object using abbreviated enums (no other keys allowed):
action:hakmalsocmiserrphyenvunkactor:extintprtunkasset:srv(server)usr(user device)ppl(person)netmedterembunkattribute:ia(integrity/authenticity),cp(confidentiality/possession),au(availability/utility),unk— VERIS pairs each CIA element with its Parkerian Hexad counterpart, so e.g.cpcovers loss of possession/control over data even without confirmed viewing/disclosure.summary: short plain-English naming the VERIS variety for that step (e.g. "Use of stolen creds", "Ransomware installed", "Data copied", "Extortion for payment").
Sequencing rules
- Order = chronology: initial access first, business impact last.
- Attribute arc tells the story: getting in / installing tooling / coercing
=
ia; data accessed or copied =cp; encryption, outage, or destruction =au. Most ransomware chains end on amal/.../auevent. - Asset per step: social steps (phishing, extortion) →
ppl; endpoint malware →usr; server compromise/encryption/exfil →srv. (The…/ppl/iasocial step satisfies the same logic as the Alter-behavior + People-asset rule above.) - Use
unkfor genuinely unknown steps — including the attribute. When the entry vector is undisclosed, the attribute is also unknown: codeunk/ext/srv/unk("initial access method not disclosed"), notunk/ext/srv/ia. Only useiaon an access step when a source actually supports an integrity impact for that step (e.g. a confirmed unauthorized modification) — don't default toiajust because "getting in" sounds integrity-shaped. Do NOT default tosoc/phishwhen the entry vector is unconfirmed. Code only what sources support; over-coding is worse than honest gaps. (The "never speculate" rule applies here too. A reviewer flagged this exact.../iadefault in PR #23592 — an unknown vector should read asunk/ext/srv/unk, and the same applies even when the action category is known, e.g.hak/ext/srv/unk, if the specific attribute isn't established.) - Known-campaign TTPs: for a named malware/crew with a well-documented chain
(MOVEit/Clop web shell, DarkSide/Ryuk kill chain), you may code the
established steps even if one article omits them — but only steps that are
well-established for that campaign; otherwise
unk. - Stay consistent with the main blocks — verify this explicitly before
writing the file, do not just aim for it while drafting. Every
actioncategory andattributeyou use in the chain should also appear in the top-levelaction.*/attribute.*blocks, and vice versa: after draftingplus.event_chain, list every top-levelattribute.*block you coded (e.g.attribute.integrity,attribute.confidentiality,attribute.availability) and confirm at least one chain event carries the matching abbreviated code (ia/cp/au). A missed case seen in review: ransomware installation coded anattribute.integrityimpact at the top level, but the chain step for installing the ransomware was left without aniaevent — if the attribute section has it, the chain must account for it. - Unknown middle steps are allowed. An
unkevent may appear at any position — including between two known steps. When you know the initial entry and the business impact but the middle is undocumented, insert an explicit bridge eventunk/ext/unk/unk(use…/unk/cpif data demonstrably left the org but the mechanism is unknown) to signal "a step occurred here but is undocumented." Never replace an unknown step with a specific action you can't cite (see rule #4). A short, partly-unkchain is more honest than a fabricated complete one.
Canonical templates
- Ransomware w/ exfiltration:
hak|soc /ext/ srv|ppl /ia(access) →mal/ext/srv/ia(ransomware installed) →mal/ext/srv/cp(data copied) →mal/ext/srv/au(encrypted) - Hack-and-extort, no malware:
hak/ext/srv/ia(access) →hak/ext/srv/cp(data stolen) →soc/ext/ppl/ia(extortion demand) - Web-exploit data theft (MOVEit-style):
hak/ext/srv/ia(vuln exploited) →mal/ext/srv/ia(web shell installed) →mal/ext/srv/cp(data stolen) →soc/ext/ppl/ia(extortion)
3. Handle unknowns automatically (no pausing)
For any field the sources don't reveal, fill the schema's "Unknown" (or
"Other") enum value rather than omitting required structure, and record each
assumption or guess in plus.analyst_notes. Do not stop to ask the user
about data gaps — only ask for the analyst handle if it is missing.
4. Fill the plus.* metadata block (what you can)
plus.master_id= the incident_id UUID.plus.analyst= the provided/asked GitHub handle.plus.sub_source="vcdb"unless the issue indicates otherwise.plus.analysis_status="First pass"— the corpus-standard marker for an initial, not-yet-reviewed encoding, which is exactly what thesubmitted/stage means. ("submitted"is not a valid enum value; the allowed values areIn-progress,Ready for review,First pass,Reviewed,Finalized,Ineligible. Do not use"Reviewed"— that is reserved for records a human has reviewed.)plus.dbir_year= the DBIR reporting year the incident falls into — which report subsets the data, not the calendar year of the incident. A DBIR year N covers the window Nov 1 of (N−2) through Oct 31 of (N−1); the boundary flips on Nov 1. Derive it from the timeline, normally the incident (begin) date:- Incident date in Jan–Oct of year Y (or year-only, which you treat as
Jan 1) →
dbir_year = Y + 1. (E.g. an incident anywhere in 2023 →2024; an incident on Oct 2 2024 →2025.) - Incident date in Nov or Dec of year Y (i.e. past the Oct 31 cutoff) →
dbir_year = Y + 2. (E.g. Nov 1 2024 →2026.) - Derive from a later timeline anchor when the incident date is imprecise.
If you only have a year for the incident but the discovery / public-disclosure
date (e.g. when it was posted to HHS or another
.gov) is more precise and later, derivedbir_yearfrom that later date using the same Nov 1 / Oct 31 window. Adbir_yearlater than the incident-date minimum is expected and fine in that case; adbir_yearearlier than the minimum is impossible (the validation rejects it).
- Incident date in Jan–Oct of year Y (or year-only, which you treat as
Jan 1) →
plus.createdandplus.modified= current UTC timestamp inYYYY-MM-DDTHH:MM:SSZform (get it withpython3 -c "import datetime; print(datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'))").plus.github= the issue number from the URL as a string (e.g."23372", not the integer — the schema requires a string here).plus.analyst_notes= assumptions, unknown fields, and sources consulted. The notes MUST begin with these two lines, in order, before any other text:Closes <issue URL>— the full GitHub issue URL this record encodes (e.g.Closes https://github.com/vz-risk/VCDB/issues/23372), so a reviewer can copy/paste it to check the work against the source issue.Encoded by AI — encode-veris-incident skill version 20260801T010623Z(use the Skill version string from the top of this file verbatim), so reviewers know the record was AI-generated and by which skill revision.
Then continue with the assumptions / unknowns / sources narrative.
plus.event_chain= the ordered attack story when the incident has more than one phase (ransomware, hack-and-extort, multi-action). Build it per the "Buildingplus.event_chain" subsection above.plus.attribute.confidentiality.data_abuse— optional; the schema defines it as whether "the data was used for fraud, used mischievously, used maliciously, or otherwise abused" — i.e. the breached data was actually misused after disclosure, not merely exposed. It is a sub-field of the confidentiality attribute, so two rules govern whether to code it at all:- Only code it when confidentiality was an affected attribute (the record
has an
attribute.confidentialityblock / data was disclosed). It must never appear on an availability-only or no-confidentiality incident (e.g. ransomware-nobreach) — that is an invalid record and the lint rejects it. Theplus.*block describing data abuse only makes sense if there is real-attribute confidentiality impact to describe. - It is not required and most records omit it — the corpus sets it on only
~1/3 of confidentiality breaches. Do not add a placeholder
"Unknown"just to populate the field. Code it only when a source actually says something about downstream use of the data; otherwise leave it out entirely. - Values, when you do code it:
"Yes - Data ransomed"when there is an extortion/ransom or leak-site component;"Yes - Identity theft","Yes - Financial fraud", or"Yes - Posted on personal forum"only when a source confirms that specific downstream abuse; bare"Yes"if abuse is confirmed but unspecified;"No"only if a source states the data was not misused;"Unknown"only when you have a specific reason to record the question as explicitly open. Never code aYes-family value just because data was stolen — abuse must be supported, and anyYes-family value requiresconfidentiality.data_disclosure == "Yes". Whenaction.social.varietyincludes"Extortion"(or there is a ransom / leak-site component), prefer the specific"Yes - Data ransomed"over a bare"Yes".
- Only code it when confidentiality was an affected attribute (the record
has an
Leave other
plus.*fields out unless the sources support them.Do not include a top-level
value_chainblock — it is a deprecated/auto-derived VERIS section the team no longer encodes. Omit it entirely, even if a fetched template or example contains one.Set top-level
schema_nameto"vcdb"(always).Set top-level
confidencefrom (a) completeness of the 4 A's and (b) source quality (enum:High/Medium/Low/None):High— all four A's (Actor, Action, Asset, Attribute) are established from the sources and at least one reputable source backs it (security press such as BleepingComputer / SecurityWeek / The Record, vendor or incident-response advisories, an official victim notice, or mainstream news).Medium— key fields are missing/Unknown, or the reporting is plausible but thinly sourced (e.g. a single personal blog).Low— sparse, largely uncorroborated, or many core fieldsUnknown.None— essentially unsupported (rare). When torn between two levels, choose the lower — do not inflate toHighjust because the record validates.
Set top-level
schema_versionto the value used by existingdata/json/validated/files (read one to confirm; do not hardcode blindly).
5. Validate against the schema (required)
Validate the object against vcdb-merged.json with jsonschema. It is not
installed by default, so install it quietly first:
python3 -m pip install --quiet jsonschema
Both stages are bundled in a script so the checks are byte-identical on
every run (do not re-type the lint logic inline). The script runs the
semantic lint FIRST, then the schema validation, and prints LINT OK then
VALID on success. Run it (paths are relative to the repo root; the script
auto-locates vcdb-merged.json, so it also works run from bin/):
python3 .claude/skills/encode-veris-incident/scripts/validate.py \
data/json/submitted/<UUID>.json
What the lint covers (Stage 1). These are cross-field coding-correctness
checks the JSON schema cannot express (a record can be schema-valid yet
mis-coded — e.g. a social action with no Alter behavior integrity impact and
no People asset). It also confirms the fields this skill is responsible for are
present and internally consistent (confidentiality.state, confidence,
schema_name/source_id, analysis_status, the Closes note), validates
optional fields if present (data_abuse must be a valid enum value and may
only appear when confidentiality was an affected attribute — it is not
required on every record), and flags over-code patterns (e.g. "abuse confirmed"
with no disclosure, an Exfiltrate result with no disclosure). It also
double-checks plus.dbir_year against the incident date using the Nov 1 / Oct 31
DBIR-year window (it rejects a dbir_year that is impossibly early for the
incident begin date; a later one, derived from a later discovery/disclosure date,
is allowed). LINT WARN lines are non-fatal
judgment calls to reconsider or justify in analyst_notes. The lint cannot
detect speculation — only you can ensure every non-Unknown value is
source-backed and that assumptions are recorded in plus.analyst_notes.
The script exits non-zero on the first stage that fails:
LINT FAIL:(exit 1) — fix every listed item yourself (do not auto-stuff placeholder values blindly — pick the correct value) and re-run.- a
jsonschemaerror (exit 1) — usually a wrong enum value or missing required field; fix and re-run. - exit 2 —
jsonschemanot installed (python3 -m pip install --quiet jsonschema, per above) or the schema file could not be located (pass--schema <path-to-vcdb-merged.json>).
Only write/keep the file once the script prints both LINT OK and VALID.
The lint rules themselves live in scripts/validate.py; if you need to change a
check, edit that file (it is version-agnostic and needs no skill-version bump).
Known required-field gotchas (set these up front to avoid re-validation loops):
victimrequiresgovernment— an array; use["NA"]for a private, non-government company.- Any
action.socialblock requires bothtargetandvector(arrays). assetrequirescloud(an array, inherited from upstream VERIS) in addition toassets. Valid values:"On-Premise Asset(s)","External Cloud Asset(s)","Other","Unknown","NA". When sources name cloud-hosted assets/services (SaaS, Azure/AWS/GCP, a GitHub repo, a cloud container registry, etc.), code"External Cloud Asset(s)"— and combine with"On-Premise Asset(s)"when both are involved. Use["On-Premise Asset(s)"]only for clearly on-prem infrastructure, and["Unknown"]when hosting is genuinely unclear (don't reflexively default to on-prem).plus.githubmust be a string.- Most enumerated fields are arrays even when there is one value
(
victim.government,victim.country,actor.*.variety/motive/name, etc.). - Required top-level
…(truncated)