Skip to content

Add VEX generation/advisor and Blueprint connection-ID resolution - #10

Merged
jason-at-contrast merged 16 commits into
mainfrom
vex-and-blueprint
Sep 3, 2026
Merged

Add VEX generation/advisor and Blueprint connection-ID resolution#10
jason-at-contrast merged 16 commits into
mainfrom
vex-and-blueprint

Conversation

@planetlevel

Copy link
Copy Markdown
Collaborator

Summary

Adds a new vex/vex-advisor subcommand pair (CycloneDX VEX generation + AI-assisted soundness review) and fixes Blueprint connection-ID resolution, plus a samples/ directory with real, current output from every generator.

VEX generation (VEXGenerator)

  • Generates a CycloneDX 1.6 VEX from Contrast's runtime library-usage and CVE Shield data, with a documented, auditable decision policy (see the class doc comment and README) for not_affected/in_triage/no-statement-at-all.
  • Enriches each claim with real Contrast data: CVE description, CVSS rating, affects[].versions[] (deployed vs. recommended-fix version, from Contrast's own remediation guidance), analysis.response[], and exploitability signals (contrast:epssScore/epssPercentile, contrast:cisaKev).
  • New per-app components[] entries report whether Assess (runtime evidence) and ADR (formerly "Protect", the classic RASP module) are enabled per environment - a duration-based claim in an Assess-blind environment is no evidence, not weak evidence.
  • New contrast:shieldAvailable - whether CVE Shield has a virtual patch for a CVE at all (a per-CVE, product-level fact from the org-wide /organizations/{org}/cves endpoint). CVEs with no Shield coverage at all get no VEX statement - "not observed executing" isn't evidence when nothing was ever watching, so neither not_affected nor in_triage is a claim this tool can support. This exclusion is counted and printed to console, not silent.

VEX soundness review (VEXAdvisor)

  • Report opens with a summary (coverage + computed Key Findings) before per-application detail, and lists every claim in one compressed table per app (CVE | Library | Score | VEX | Shield | Rationale) instead of duplicate lists or a full-sentence rationale repeated per claim.
  • Which claims need human review is decided deterministically (severity, CISA KEV, EPSS, Assess/ADR enablement are all plain facts already on the VEX) rather than via a per-statement AI call - the AI call previously classified 235 statements individually, which took ~8 minutes, ~30,000 output tokens, and produced a different flagged count from run to run on identical input (21, 61, 125 across one session). The single remaining AI call per app is narrative-only, given the already-decided flagged list as context. Cuts a full vex --analyze run from ~8 minutes/$0.60+ to under 30 seconds/$0.024, with a reproducible result every time.

Blueprint

  • Fixes connection-ID resolution so architecture-graph connections known only by ID (not name) still produce a valid asset/flow.

Samples

  • samples/ has one real, current output from every generator (cbom, aibom, blueprint, vex) and its matching advisor report, each scoped to whichever application in this org actually has real data for that report type.

Testing

  • Every VEX output in this PR has been schema-validated against CycloneDX 1.6 via cyclonedx-core-java's own JsonParser.validate().
  • Generators and the advisor have been run live against this org's Contrast instance throughout development; samples/ reflects real output, not fixtures.

- VEXGenerator: new `vex` subcommand generating a CycloneDX 1.6 VEX for
  an application's library CVEs, using Contrast's Libraries (SCA) and
  CVE Shield/Protect APIs. Policy: code_not_reachable when a library's
  classes were never loaded, protected_at_runtime when CVE Shield/Protect
  is active, no statement for exposed/exploited CVEs, and a duration-gated
  not_affected/in_triage claim (--vex-accept-after-days) when a CVE's
  code path has never been observed executing. --env scopes claims to a
  single environment instead of the app's dev/qa/prod combined.
- VEXAdvisor: new `vex-advisor` subcommand / `vex --analyze` - AI review
  of whether each generated VEX claim is safe to rely on given the CVE's
  severity/exploitability, writing contrast:vexAdvisorAssessment/Rationale
  back onto each vulnerability.
- BlueprintGenerator: new `blueprint` subcommand (CycloneDX 2.0-dev draft
  ABOM + Bill of Behaviors), wired into Main.
- AppGraphInfo/ApplicationGraphFetcher: track connected application IDs
  alongside display names, so Blueprint can resolve a graph connection
  back to an existing asset instead of creating a duplicate.
- README: document all of the above.

🤖 Generated with Claude Code
…signals

VEXGenerator now populates several CycloneDX VEX fields that Contrast's
API already returns but the generator wasn't capturing:

- vulnerability.description (from the CVE record)
- affects[].versions[] (deployed version as affected, Contrast's
  minUpgrade guidance - or latest_version as a fallback - as unaffected)
- recommendation (built from Contrast's own minUpgrade/maxUpgrade
  remediation guidance where available)
- analysis.response[] (update when a fix version is known,
  workaround_available for protected_at_runtime claims)
- contrast:epssScore/epssPercentile and contrast:cisaKev properties
  (EPSS exploit-prediction score and CISA Known Exploited
  Vulnerabilities catalog membership)

All of this is deterministic and sourced directly from Contrast's own
CVE/library data - no AI involved in the claim itself, since a VEX
statement is an attestation that needs to stay auditable.

VEXAdvisor now actually receives the EPSS/CISA KEV signals its prompt
already claimed to weigh (they were referenced in ANALYSIS_PROMPT but
never parsed from the VEX or passed to the model), so its soundness
judgment is grounded in real exploitability data instead of CVSS alone.

Verified: builds clean, output round-trips through the CycloneDX 1.6
JSON schema validator, and a live run against SAML-PetClinic-Demo
confirms real EPSS/CISA KEV values (including known KEV entries like
CVE-2022-22965) and the advisor citing them in its rationale.
Six files captured from a single live run against the org:

- sample-cbom.json / sample-cbom-advisor.md
- sample-aibom.json / sample-aibom-advisor.md
- sample-blueprint.json (no advisor - blueprint doesn't have one)
- sample-vex.json / sample-vex-advisor.md (scoped to SAML-PetClinic-Demo;
  a full-org VEX run is one CVE-issues API call per app and produces a
  much larger file for no added illustrative value)

sample-vex.json also exercises the description/recommendation/response/
EPSS/CISA-KEV enrichment from the prior commit - includes real KEV hits
(e.g. CVE-2022-22965).
cbom/aibom/blueprint were previously whole-org; rescoped with --app so
every sample now reflects a single-application run, consistent with
vex (which was already scoped to SAML-PetClinic-Demo).

Crypto usage, AI usage, and vulnerable libraries happen to show up in
three disjoint sets of applications in this org, so no single app has
non-empty data for all four generators - each sample uses whichever
app actually has real data for its report type (documented in the
README table):

- cbom / blueprint -> Cargo-Crypto-contrast-cargo-cats-frontgateservice
- aibom -> Robert-cargocats-aiservice
- vex -> SAML-PetClinic-Demo (unchanged)
…ayout

The report previously listed every CVE twice - once in a per-CVE table
(CVE/Library/Severity/State/Assessment), then again as an almost
identical bullet list repeating the same CVEs with a one-line
rationale. Merged into one table per app: needs_review rows carry
their rationale inline, sound rows stay in a compact collapsible list
without repeating rationale text (235 statements were making this
report's "Application Review" section over 500 lines for one app).

Report now opens with a Summary section before any per-app detail:
what the report is, what it covers (app/statement counts), and
computed Key Findings bullets - flagged-claim count, CISA KEV-listed
CVEs among the flagged claims (deduplicated), high-EPSS (>=0.5)
flagged claims, and which apps rated CRITICAL/HIGH. These are computed
directly from the VEX's own contrast:cisaKev/epssScore properties, not
AI-generated, so they stay auditable.

Also fixed a related latent bug found while rewriting this:
writeAssessmentsToVex appended contrast:vexAdvisorAssessment/Rationale
properties without removing prior ones, so re-running vex-advisor on
the same file accumulated duplicate properties per vulnerability.
Verified idempotent now - a second run stays at 235 properties.

Regenerated samples/sample-vex.json and sample-vex-advisor.md against
SAML-PetClinic-Demo to reflect both fixes (report went from 533 to
311 lines with the same underlying data).
…ed rationale

Replaces the separate Needs Review / Sound tables with a single table
per app: CVE | Library | Score | VEX | Rationale. Narrower on every axis:

- Library is "artifact@version" (e.g. jackson-databind@2.8.8) instead
  of the full backtick-quoted purl
- Severity text (critical/high/...) replaced with the numeric CVSS score
- State abbreviated to NA/IT
- Rationale is a 3-letter code instead of a full-sentence AI rationale
  repeated per row: UNU (code_not_reachable), SHD (protected_at_runtime),
  AGD (duration-based not_affected), WCH (in_triage) - with a `!` suffix
  when the VEX Advisor flagged that specific claim needs_review

A Legend section (once, before the per-app tables) decodes all of it.
Flagged rows sort to the top of each table, by severity, so the claims
that matter are visible without scanning 235 rows. The per-row rationale
sentence is gone from the table entirely - the app-level Risk Rationale/
Recommendation paragraphs already explain what's driving the flags.

Regenerated samples/sample-vex.json and sample-vex-advisor.md against
SAML-PetClinic-Demo.
Replaces the "!" suffix on the Rationale code with a dedicated Action
column, since the whole point of this report is telling a reviewer what
to do next:

- Safe: structural justification (UNU/SHD) - trust as-is regardless of
  severity
- Monitor: duration-based justification (AGD/WCH), advisor assessed it
  as sound for now
- Review: flagged needs_review by the advisor

Rows now sort Review > Monitor > Safe (by severity within each) so the
claims needing attention are grouped at the top of each table. Legend
and appendix updated to match. Regenerated samples/sample-vex.json and
sample-vex-advisor.md against SAML-PetClinic-Demo.
actions/checkout v4->v5, actions/setup-java v4->v5, actions/upload-artifact
v4->v5 - resolves the Node.js 20 deprecation warning (v4 actions were being
forced onto Node 24) and the setup-java v4 deprecation notice.

🤖 Generated with Claude Code
Rationale is now one full word instead of a 3-letter code, and the two
duration-based reasons carry their day count: Unused, Shielded,
Aged 288d, Watching 12d (using daysObserved, already parsed onto
VexStatement but previously unused in the table).

Removed the Action column (Safe/Monitor/Review) added last commit.
Review and Monitor turned out to prescribe no distinct action - both
mean "look at it or consider upgrading," and there's nothing concrete
a reviewer can do to "confirm" a duration-based claim beyond what the
per-app Recommendation text already says. Rather than a column that
implies a decision framework it doesn't back up, rows now sort by
actual risk signal instead: CISA KEV-listed first, then EPSS score,
then CVSS score - so the claims worth a second look surface at the
top, and the Key Findings section already names which specific CVEs
those are.

Regenerated samples/sample-vex.json and sample-vex-advisor.md against
SAML-PetClinic-Demo.
…ed Nd / CVE Watching Nd

"Aged Nd" read as generic staleness rather than what it actually means -
zero observed executions of the vulnerable path over the full N-day
monitoring window. Renamed to "CVE Not Used Nd", and aligned the other
three labels to the same "Library ___" / "CVE ___" pattern for
consistency: Library Unused, CVE Shielded, CVE Not Used Nd, CVE
Watching Nd.

Regenerated samples/sample-vex.json and sample-vex-advisor.md against
SAML-PetClinic-Demo.
Adds a fourth data source to VEXGenerator - GET
/Contrast/api/ng/{org}/applications/{id}/servers - which exposes
per-server, per-environment `assess` and `defend` enablement flags.
Surfaced as a new Application component (bom.components[], one per
app, not repeated per statement) with contrast:assessEnabledDev/Qa/Prod
and contrast:protectEnabledDev/Qa/Prod properties ("true"/"false"/""
- empty means no agent was ever seen in that environment, a different
fact from "disabled").

This closes a real gap: a duration-based not_affected claim scoped to
an environment where Assess itself has no data isn't weak evidence,
it's no evidence at all - the tool had no way to say so. For
SAML-PetClinic-Demo this is concrete: Assess has no data for QA at
all, and Protect is disabled in dev despite being enabled in prod.

Named `protectEnabled*`, not `shieldEnabled*` - went through two
corrections getting here. First cut conflated "CVE Shield" with the
API's `defend` flag, which is actually the older, separate HTTP-rule
RASP module (Protect). CVE Shield is a newer product that defends
specific CVEs via a microsandbox, and there is no distinct enablement
flag for it anywhere in this API - so this reports Assess/Protect
only, and says so explicitly in the Legend, the doc comments, and the
VEX Advisor's prompt/report, rather than fabricating an "ADR" or "CVE
Shield enabled" signal that doesn't exist. CVE Shield's own per-CVE
verdicts (PROTECTING/BLOCKED) still surface as they always did, via
the protected_at_runtime justification and devStatus/qaStatus/
prodStatus properties.

VEXAdvisor: parses the new component properties into AppEntry, adds a
per-app "Protection Status" line to the report, and feeds the
enablement facts into the AI prompt so duration-based claims in an
Assess-blind environment get flagged regardless of severity.

Regenerated samples/sample-vex.json and sample-vex-advisor.md against
SAML-PetClinic-Demo; re-validated against the CycloneDX 1.6 schema.
…ed-CVE bug

Rebrand: "Protect" is now "ADR" per product naming. Renamed
contrast:protectEnabled* -> contrast:adrEnabled*, ProtectionStatus ->
ModuleStatus, and all display text/prompts accordingly.

CVE Shield availability: added GET /organizations/{org}/cves (org-wide,
paginated) for cveShieldExists per CVE. More importantly, scanning
every application in this org's cves/issues data turned up a real,
previously-unhandled per-environment status value: NO_SHIELD, distinct
from NOT_SEEN. NO_SHIELD means CVE Shield has no coverage for that CVE
in that environment at all - "we haven't seen it" means nothing when
nothing was capable of catching it. NOT_SEEN means Shield exists there
and simply hasn't fired - a materially different, stronger claim.

This was a real bug, not just a missing feature: NOT_SEEN_STATUSES only
matched "NOT_SEEN", so a CVE with NO_SHIELD in every considered
environment fell through to the final "never suppress" branch and got
DROPPED from the VEX entirely - no statement, no explanation, silently
missing. For SAML-PetClinic-Demo this recovered 16 previously-invisible
CVEs (235 -> 251 statements). Fixed by adding NO_SHIELD to
NOT_SEEN_STATUSES (same duration-based logic applies) while tracking it
separately via a new shieldAvailability() helper, preferring the
per-app/per-env signal over the org-wide cveShieldExists fallback.

Surfaced as contrast:shieldAvailable ("true"/"false", per statement) and
a new **Shield** column (Yes/No/-) in the VEX Advisor's compressed
table - your explicit ask: "if shield is available, report the ability
to enable it." Fed into the AI prompt and Legend too, and the detail
text for CVE Not Used claims now says so when Shield has zero coverage.

Also simplified remaining "CVE Shield/Protect" phrasing (pre-existing,
describing the per-CVE protected_at_runtime justification) to just "CVE
Shield", now that NO_SHIELD/protectingCount/blockedCount confirm that
data is CVE Shield's own domain, not the general ADR/RASP module.

Regenerated samples/sample-vex.json and sample-vex-advisor.md against
SAML-PetClinic-Demo; re-validated against the CycloneDX 1.6 schema.
A claim's whole justification for "not_affected" via duration is
"we watched for N days and never saw it exploited." That requires
something to have been watching. When shieldAvailability() is false -
every environment considered is NO_SHIELD - there was no detector at
all, so elapsed time proves nothing, and the previous commit's fix
was still wrong: it let these graduate to not_affected once
daysObserved crossed the threshold anyway (e.g. CVE-2020-11996 on
tomcat-embed-core showed VEX=NA, Shield=No, which is self-contradictory
- "not seen" from a detector that doesn't exist isn't evidence).

Fixed: when shieldAvailability() is false, the claim now stays
in_triage permanently, regardless of daysObserved - it can never
graduate to not_affected on duration alone. Applies to both the
per-app NO_SHIELD branch and the issue==null/org-wide-fallback branch.
The 2 statements that are code_not_reachable (0 classes loaded) are
correctly unaffected by this - that justification has nothing to do
with Shield.

Verified against SAML-PetClinic-Demo: CVE-2020-11996 is now IT/No/
"CVE Watching 288d" instead of the previous NA/No/"CVE Not Used 288d".
16 of 251 statements are affected (all NO_SHIELD-only cases). Still
251 total - none dropped, matching the intent of two commits ago
(don't silently drop CVEs) without the over-claim this introduced.

Regenerated samples/sample-vex.json and sample-vex-advisor.md;
re-validated against the CycloneDX 1.6 schema.
"CVE Watching 288d" was still misleading after the last fix: it's the
same label used for a claim genuinely still accumulating days toward
the acceptance threshold, but 288 days against a 30-day threshold
reads as a bug, not the intended "can never graduate" state.

Added a distinct rationale word - "No Shield Coverage Nd" - for
in_triage claims where shieldAvailable is false, so the table itself
makes clear this is permanent, not still-in-progress. Legend updated
to explain both in_triage variants separately.

Regenerated samples/sample-vex-advisor.md by re-running vex-advisor
against the existing sample-vex.json (no generator changes needed -
this was a report-layer wording fix).
CVE Shield coverage is a per-CVE, product-level fact (does Contrast
ship a virtual patch for this CVE at all) - not something that varies
by app or environment. If Shield covers a CVE anywhere, it's available
everywhere Shield/ADR is enabled. The previous shieldAvailability()
tried to infer availability from per-app NO_SHIELD/NOT_SEEN status,
falling back to the org-wide cveShieldExists flag only when there was
no per-app signal - solving the wrong problem, since that per-app
status can't distinguish "no coverage exists" from other per-agent
gaps, and coverage itself isn't app/environment-scoped to begin with.

Simplified: shieldAvailability() is now just the org-wide
cveShieldExists flag from /organizations/{org}/cves, verbatim. Verified
this doesn't change any current results (checked - zero cases in the
sample data where NO_SHIELD and a real status like NOT_SEEN appeared
together for the same CVE across environments, consistent with the
per-CVE model), but it's the conceptually correct source of truth and
removes a heuristic that could misfire on data this session never
happened to exercise (e.g. a per-app join gap that isn't actually a
coverage gap).

Updated wording throughout (VEXAdvisor prompt, Legend, formatAppForAi,
README) to stop describing Shield availability as environment-scoped.

Regenerated samples/sample-vex.json and sample-vex-advisor.md;
re-validated against the CycloneDX 1.6 schema.
…aim in_triage

"In triage" implies evidence is accumulating toward a future resolution.
With no CVE Shield coverage, nothing is running that could ever produce
one - marking these in_triage forever was still wrong (spotted via
CVE-2020-10693/hibernate-validator: IT + Shield=No + "288d" reads as
a real CVE dressed up as a pending VEX claim, not what it actually is).

Fixed: no-Shield-coverage CVEs now get no VEX statement at all, joining
the same "can't positively account for it" bucket as EXPOSED/EXPLOITED
(decision rule 3, renumbered). This exactly reverts to the tool's
original silent-drop behavior for this one case - but now it's a
documented, counted policy decision instead of an accidental status-
matching gap: console output reports "Excluded N CVE(s)... no CVE
Shield coverage at all" so it isn't silent. Verified against
SAML-PetClinic-Demo: 235 statements (was 251 immediately post-bug,
235 pre-bug) - the 16 no-coverage CVEs (including CVE-2020-13935,
CVE-2020-10693, CVE-2020-11996) are gone from the VEX, not misrepresented
in it.

VEXAdvisor: bigger change - made per-statement review flagging fully
deterministic instead of an AI judgment call. Every input the AI was
asked to weigh (severity, CISA KEV, EPSS, Assess/ADR enablement) was
already a plain fact on the VEX; classifying 235 statements over an AI
round-trip added ~8 minutes and ~30,000 output tokens per run without
adding judgment, and produced a different flagged count from run to
run on identical input (21, 61, 125 across this session) - a real
liability for a report meant to be relied on. The one AI call per app
is now narrative-only (app description/risk level/rationale/
recommendation), given the already-decided flagged list as context
instead of all 235 statements individually. Cut a full vex --analyze
run on SAML-PetClinic-Demo from ~8 minutes/$0.60+ to under 30
seconds/$0.024, same reproducible flagged count every time.

Removed the now-dead "No Shield Coverage Nd" rationale word and its
Legend row (unreachable now that VEXGenerator excludes those CVEs
outright) and the "statements" array from the AI's output schema.

Regenerated samples/sample-vex.json and sample-vex-advisor.md;
re-validated against the CycloneDX 1.6 schema.

@jason-at-contrast jason-at-contrast left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some a couple things in the readme and samples which have hard coded endpoint values like going to some org in eval, however given that they are samples or the point trying to get across is an organization in contrast, i see nothing wrong with it. If we wanted to make it better, we could have "your instance" in the same place so the person who is trying to leverage this knows to change it. too bad we couldn't have custom readme's based on their .env's... maybe someday.

@jason-at-contrast
jason-at-contrast merged commit 10a537f into main Sep 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants