Add VEX generation/advisor and Blueprint connection-ID resolution - #10
Merged
Conversation
- 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
approved these changes
Sep 3, 2026
jason-at-contrast
left a comment
Collaborator
There was a problem hiding this comment.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new
vex/vex-advisorsubcommand pair (CycloneDX VEX generation + AI-assisted soundness review) and fixes Blueprint connection-ID resolution, plus asamples/directory with real, current output from every generator.VEX generation (
VEXGenerator)not_affected/in_triage/no-statement-at-all.affects[].versions[](deployed vs. recommended-fix version, from Contrast's own remediation guidance),analysis.response[], and exploitability signals (contrast:epssScore/epssPercentile,contrast:cisaKev).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.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}/cvesendpoint). CVEs with no Shield coverage at all get no VEX statement - "not observed executing" isn't evidence when nothing was ever watching, so neithernot_affectednorin_triageis a claim this tool can support. This exclusion is counted and printed to console, not silent.VEX soundness review (
VEXAdvisor)CVE | Library | Score | VEX | Shield | Rationale) instead of duplicate lists or a full-sentence rationale repeated per claim.vex --analyzerun from ~8 minutes/$0.60+ to under 30 seconds/$0.024, with a reproducible result every time.Blueprint
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
cyclonedx-core-java's ownJsonParser.validate().samples/reflects real output, not fixtures.