Skip to content

feat: add exploit-intelligence job polling, configuration & API endpoints#2464

Open
Strum355 wants to merge 25 commits into
guacsec:feature/exploit-intelligence-integrationfrom
Strum355:TC-4677
Open

feat: add exploit-intelligence job polling, configuration & API endpoints#2464
Strum355 wants to merge 25 commits into
guacsec:feature/exploit-intelligence-integrationfrom
Strum355:TC-4677

Conversation

@Strum355

@Strum355 Strum355 commented Jul 8, 2026

Copy link
Copy Markdown
Member

Summary

Add the Exploit Intelligence orchestration module to trustify. This module provides API endpoints for the UI to trigger automated vulnerability reachability analysis via the EI client service (agent-morpheus-client), and a background worker that submits SBOMs, polls for results, and ingests returned CSAF VEX documents into trustify's advisory system.

Jira

Implements TC-4677

What changed

New module: exploit_intelligence (modules/fundamental/src/exploit_intelligence/)

  • endpoints/ — HTTP handlers for triggering analysis and querying job status
  • runner/ — background processing: SBOM upload with retry, polling for results, orphaned job recovery, heartbeat coordination
  • service/ — CRUD operations on job and component entities, recovery queries
  • model/ — request/response DTOs and API models
  • auth.rs — OIDC client credentials and static token authentication for the EI service
  • ei_client.rs — response type definitions for the EI client API

API endpoints:

  • POST /v3/exploit-intelligence/analyze — trigger analysis for a CVE against an SBOM. Returns existing active job if one already exists (deduplication).
  • GET /v3/exploit-intelligence/jobs — paginated job list, filterable by sbom_id and status
  • GET /v3/exploit-intelligence/jobs/{id} — job details including per-component findings, advisory links, and report URLs

Analysis flows:

  • CycloneDX (single-component): upload to EI → poll /api/v1/reports/by-scan-id/{scanId} → extract finding → ingest VEX if available
  • SPDX (multi-component): upload to EI → poll /api/v1/products/{productId} → fetch per-component reports → extract findings and ingest VEX documents individually

Retry and polling:

  • Upload retries use the backon crate (already a transitive dependency via walker-common) with configurable exponential backoff
  • Polling uses backon::ConstantBuilder with a Cell-based consecutive failure counter for distinguishing transient errors from permanent failures
  • All retry/poll parameters are configurable via environment variables using humantime format

Multi-instance safety:

  • Heartbeat-based optimistic locking prevents multiple trustify instances from polling the same job. A heartbeat column (nanosecond timestamp) on the job table serves as a CAS lock — UPDATE ... WHERE heartbeat = <expected> ensures mutual exclusion.
  • SELECT ... FOR UPDATE SKIP LOCKED during startup recovery atomically claims orphaned jobs
  • CancellationToken for cooperative work cancellation when the heartbeat is lost
  • All terminal status updates (update_job_completed, update_job_failed) use CAS against the heartbeat value — only the owning instance can transition job state
  • Partial unique index (sbom_id, vulnerability_id) WHERE status IN ('pending', 'running') prevents duplicate active jobs at the database level

EI-generated advisory labeling:

  • Ingested VEX documents are labeled source: exploit-intelligence to distinguish AI-generated findings from vendor-published advisories

Configuration (environment variables):

Variable Description Default
EXPLOIT_INTELLIGENCE_URL Base URL of the EI client service
EXPLOIT_INTELLIGENCE_UI_URL EI web UI base URL for report deep-links
EXPLOIT_INTELLIGENCE_POLL_INTERVAL Polling interval (humantime) 30s
EXPLOIT_INTELLIGENCE_MAX_POLL_DURATION Max polling duration (humantime) 30m
EXPLOIT_INTELLIGENCE_UPLOAD_MAX_RETRIES Upload retry attempts 3
EXPLOIT_INTELLIGENCE_UPLOAD_RETRY_DELAY Initial retry delay (humantime, exponential) 1s
EXPLOIT_INTELLIGENCE_MAX_CONSECUTIVE_POLL_FAILURES Consecutive poll failures before abort 5
EXPLOIT_INTELLIGENCE_AUTH_TOKEN Static auth token (fallback)
EXPLOIT_INTELLIGENCE_OIDC_TOKEN_URL OIDC token endpoint
EXPLOIT_INTELLIGENCE_OIDC_CLIENT_ID OIDC client ID
EXPLOIT_INTELLIGENCE_OIDC_CLIENT_SECRET OIDC client secret

Migration changes (m0002230):

  • Adds heartbeat DECIMAL NULL column to exploit_intelligence_job table
  • Adds partial unique index idx_ei_job_active_dedup on (sbom_id, vulnerability_id) for active jobs

Dependencies

  • Depends on TC-4675 / PR #2435 (entity and migration for job tracking)
  • Adds backon as a direct workspace dependency (already transitive via walker-common)
  • Adds humantime to the server crate for duration configuration
  • Adds tokio-util to fundamental for CancellationToken

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry @Strum355, your pull request is larger than the review limit of 150000 diff characters

@Strum355 Strum355 changed the title Tc 4677 feat: add exploit-intelligence job polling, configuration & API endpoints Jul 8, 2026
Comment thread docs/env-vars.md Outdated
Comment thread docs/env-vars.md Outdated
@ctron

ctron commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Please consider that this code can run multiple times on the same database, in multiple instances.

@ruromero ruromero left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The functionality is there and should work as expected but all the logic ended up in the endpoints module which is an anti-pattern. Look how other functionalities are implemented. I wonder why Claude didn't check for these patterns.

Another concerning point is the fact that there are many hardcoded values that could be configurable for operational reasons. I know there should be a balance between what should be configurable vs hardcoded. It's just something to take into account.

Pay attention at the long functions (+200 lines) doing multiple steps. This goes against the defined conventions.

Finally, the whole retry/polling logic can be moved to a common module so other parts of the code could benefit from it. I haven't checked if there are other candidates for future refactoring.

Comment thread modules/fundamental/src/exploit_intelligence/endpoints/mod.rs Outdated
Comment thread modules/fundamental/src/exploit_intelligence/endpoints/mod.rs Outdated
Comment thread modules/fundamental/src/exploit_intelligence/endpoints/mod.rs Outdated
///
/// Updates both the component record (with finding/advisory) and the parent
/// job record (status only) upon completion.
pub(crate) async fn poll_for_result(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Adding a distinction between RetryableFailure vs PermanentFailure would help end users distinguish between something that can be reattempted or something that requires an action before retry.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That would require a lot of redesigning across the stack including the UI. Ultimately, all failures are retryable to some degree, Im not sure how much value add there is to adding a typed distinction that the error message itself doesnt show

Comment thread modules/fundamental/src/exploit_intelligence/endpoints/mod.rs Outdated
Comment thread modules/fundamental/src/exploit_intelligence/auth.rs Outdated
Comment thread modules/fundamental/src/exploit_intelligence/auth.rs Outdated
Comment thread modules/fundamental/src/exploit_intelligence/endpoints/mod.rs Outdated
Comment thread common/src/retry.rs Outdated
@Strum355

Copy link
Copy Markdown
Member Author

Please consider that this code can run multiple times on the same database, in multiple instances.

Thanks for pointing this out @ctron. Commit a7f1426 adds a heartbeat ownership mechanism similar to what exists for the ingestor. Theres a couple differences of course as the processes arent the same (EI jobs are request-driven), feel free to take a look and let me know if you come across any glaring issues with the implementation, or if anything could use inline clarification 🙂

@Strum355
Strum355 marked this pull request as ready for review July 14, 2026 13:34

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry @Strum355, your pull request is larger than the review limit of 150000 diff characters

@Strum355
Strum355 force-pushed the TC-4677 branch 2 times, most recently from 723c26e to 79c41bb Compare July 14, 2026 15:22
Comment thread entity/src/exploit_intelligence_job.rs Outdated
(status = 503, description = "Exploit Intelligence service not configured"),
),
)]
#[post("/v3/exploit-intelligence/analyze")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not sure we're using that pattern so far. Maybe check.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

What pattern specifically is this referring to? The endpoint path?

use uuid::Uuid;

/// Register exploit intelligence endpoints and spawn recovery for orphaned jobs.
pub fn configure(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If EI is not available, how do we ensure those endpoints are not present?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Or, what's our strategy here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Is not mounting endpoints the approach we want to go with? Afaik that would be a first for the codebase, but Im fine with that if its an approach you feel strongly about. 503 Unavailable is an existing pattern in a few places in trustify, but the conditions are probably distinct enough that we can deviate here if you wish

Comment thread server/src/profile/api.rs
Comment thread server/src/profile/api.rs Outdated
Comment thread server/src/profile/api.rs Outdated
Comment thread server/src/profile/api.rs Outdated
Comment thread modules/fundamental/src/exploit_intelligence/endpoints/mod.rs Outdated
Comment thread modules/fundamental/src/exploit_intelligence/runner/analysis.rs
Comment thread modules/fundamental/src/exploit_intelligence/runner/analysis.rs Outdated
Comment thread modules/fundamental/src/exploit_intelligence/runner/analysis.rs Outdated
Comment thread modules/fundamental/src/exploit_intelligence/runner/analysis.rs Outdated
Comment thread modules/fundamental/src/exploit_intelligence/runner/analysis.rs
@Strum355
Strum355 force-pushed the feature/exploit-intelligence-integration branch from 9155eea to 9268edd Compare July 16, 2026 10:09
Strum355 and others added 22 commits July 16, 2026 11:10
Add a new exploit_intelligence module under modules/fundamental that
provides API endpoints for triggering Exploit Intelligence analysis,
listing jobs, and retrieving job details. The module includes:

- POST /v3/exploit-intelligence/analyze: Creates an analysis job and
  spawns a background task that submits the SBOM to the EI client
  service, polls for completion, and ingests returned VEX documents
  with source: exploit-intelligence labels.
- GET /v3/exploit-intelligence/jobs: Paginated list of analysis jobs
  with filtering by sbom_id and status.
- GET /v3/exploit-intelligence/jobs/{id}: Job details including status,
  finding, report_url, advisory_id, and error details.

Configuration is via environment variables: EXPLOIT_INTELLIGENCE_URL,
EXPLOIT_INTELLIGENCE_POLL_INTERVAL_SECS, EXPLOIT_INTELLIGENCE_AUTH_TOKEN.

Implements TC-4677

Assisted-by: Claude Code
…d integration tests

- Add max_poll_duration_secs field to ExploitIntelligenceConfig (default 1800s)
  with EXPLOIT_INTELLIGENCE_MAX_POLL_DURATION_SECS env var
- Add polling timeout logic in run_analysis: calculates max iterations from
  config and breaks with Failed status when exceeded
- Add find_active_job method for deduplication: queries for Pending/Running jobs
  matching the same sbom_id + vulnerability_id
- In analyze endpoint, check for active jobs before creating duplicates
- Add three integration tests: deduplication, timeout failure, finding-only flow
- Document new env var in docs/env-vars.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The entity layer already has a proper Finding enum (Vulnerable,
NotVulnerable, Uncertain), but the API DTOs were converting it to
Option<String>. This exposes a JobFinding enum in the API model,
matching the pattern used by JobStatus.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…typed structs

The EI response was parsed via serde_json::Value with hardcoded JSON
Pointer paths, making the code silently break on any upstream schema
change. Introduces typed Rust structs (UploadResponse, PollResponse,
etc.) so contract mismatches surface as deserialization errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nnection pooling

reqwest::Client was created bare with no timeouts or pooling config,
and a new instance was constructed per background task. Now a shared
client is built once with connect_timeout(10s), request timeout(60s),
pool_max_idle_per_host(4), and pool_idle_timeout(90s).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…n refresh

Replaces the static bearer token with a TokenProvider abstraction
supporting both static tokens (backward compatible via
EXPLOIT_INTELLIGENCE_AUTH_TOKEN) and OIDC client credentials flow
with automatic token refresh before expiry.

New env vars: EXPLOIT_INTELLIGENCE_OIDC_TOKEN_URL,
EXPLOIT_INTELLIGENCE_OIDC_CLIENT_ID,
EXPLOIT_INTELLIGENCE_OIDC_CLIENT_SECRET.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Upload requests retry up to 3 times with exponential backoff (1s, 2s,
4s) on transient HTTP errors (429, 502, 503, 504) or network failures.

Poll loop classifies errors as transient (retryable) or permanent
(immediate failure). Up to 5 consecutive transient poll failures are
tolerated before the job is marked failed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
After a server restart, EI jobs that were Pending or Running become
orphaned because their background tasks no longer exist.

- Pending jobs (no scan_id) are marked Failed with a descriptive message
  telling the user to re-trigger the analysis.
- Running jobs (scan_id present) get their poll loops re-spawned so the
  EI backend result is picked up transparently.

Recovery runs inside the EI endpoints `configure()` where the actix
runtime is available, using `actix_web::rt::spawn` (not `tokio::spawn`)
because IngestorService contains non-Send types.

The poll loop is extracted into a standalone `poll_for_result()` function
shared by `run_analysis` (normal path) and `recover_and_resume` (restart
recovery path).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…p-links

The report_url was incorrectly built from the API base URL (config.url),
which points to the agent-morpheus-client backend. The EI web UI may be
hosted at a different URL. Add EXPLOIT_INTELLIGENCE_UI_URL as a separate
optional config — when set, completed jobs include clickable deep-links
to the EI web UI; when unset, report_url is null.

Also fix pre-existing test compilation issues (missing entity fields,
wrong assertion method on JobFinding enum).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Regenerate OpenAPI spec with missing JobFinding, ComponentResult schemas and fields
- Fix TOCTOU race in analyze endpoint by using single RW transaction for dedup check-and-insert
- Add test authorizer to all read endpoint tests (get_job, list_jobs)
- Fix advisory_id parsing by stripping urn:uuid: prefix before Uuid::from_str
- Return 200 OK for deduplicated jobs instead of 201 Created
- Enforce minimum 5s poll interval via clap validation and defense-in-depth .max(5)
- Replace magic integer literals with named Finding enum constants in aggregate query
- Make total count conditional on paginated.total to skip unnecessary count queries
- Make component initialization idempotent for orphaned jobs by checking existing records
- Use read-only transaction for SBOM retrieval in run_analysis
- URL-encode path segments in all EI client API and report URL construction
- Add tests: happy-path analyze, running status, status filter, 404 SBOM not found, 503 EI unconfigured
- Document EXPLOIT_INTELLIGENCE_UI_URL environment variable
- Warn when OIDC args are partially configured instead of silently falling back
- Increase default OIDC expires_in from 300s to 3600s
- Add err.is_request() to transient network error detection for DNS failures
- Log warning when SBOM format label is missing and defaulting to CycloneDX
- Add doc comments to all ComponentResult fields
- Introduce EiRuntime wrapper struct for config/client coupling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… fix SubmissionFailure deserialization

Rename component_purl → component_ref in the API model and service layer
to reflect that the field stores both purls and container image references.

Fix SubmissionFailure.purl → .image to match what morpheus-client actually
sends, resolving silent deserialization failures that stored "unknown" for
failed component references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The EI service returns finding status as uppercase ("TRUE"/"FALSE") but
the mapping matched lowercase only, causing all findings to fall through
to "uncertain". Use to_ascii_lowercase() before matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rework the EI orchestration layer for the unified schema where all
per-analysis results (scan_id, finding, advisory_id) live on the
component table — even CycloneDX single-component jobs now create
exactly one component row.

Key changes:
- CycloneDX flow creates a component record before upload, stores
  scan_id on the component via update_component_running
- Delete update_job_running (scan_id no longer on job)
- Simplify update_job_completed to status-only (no finding/advisory)
- Recovery loads components from DB instead of reading job.scan_id
- SPDX flow uses GET /reports?productId= to discover component scan
  IDs and findings, since the product endpoint returns empty components
- poll_for_result takes component_id and updates component on completion
- report_url computed at API response time from ui_url + identifiers
- Aggregate finding computed from component data in model layer
- Remove component_name (dropped from entity)
- Remove advisory_id from job details (lives on components)
- Add ProductReportEntry/ReportVuln structs for reports-list endpoint
- Fix ProductData deserialization (alias "id", default components)

Depends on TC-4675 entity/migration changes (will not compile alone).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace custom common::retry module with the backon crate, which is
already a transitive dependency via walker-common. Upload retries use
backon's ExponentialBuilder directly, and polling uses ConstantBuilder
with a Cell-based consecutive failure counter in the .when() predicate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… runner module

Move background processing functions (run_analysis, poll_for_result,
poll_for_product_result, recover_and_resume) out of the endpoints layer
into a new runner/ module. Endpoints now contain only HTTP handlers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ith humantime

Switch poll interval and max duration env vars from raw seconds to
humantime format, and make upload retry count, retry delay, and max
consecutive poll failures configurable via new env vars with reasonable
defaults. Also fix clippy expect_used violation in service constructor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…safety

Add optimistic-locking heartbeat mechanism to prevent multiple trustify
instances from polling the same EI job concurrently. Recovery uses
SELECT ... FOR UPDATE SKIP LOCKED to atomically claim orphaned jobs.

Key design decisions:
- CAS guards (WHERE heartbeat = <expected>) on all terminal status updates
  so only the owning instance can transition job state
- CancellationToken for cooperative work cancellation on heartbeat loss
- Partial unique index on (sbom_id, vulnerability_id) for active jobs
  to prevent duplicate job creation races
- SharedHeartbeat (Arc<Mutex>) threaded through poll functions for
  ownership verification at every DB write

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… OIDC client builder

Change TokenProvider::oidc() to return Result and propagate the error
to the caller, fixing the clippy::expect_used violation that fails CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…stify-auth infrastructure

Replace the hand-rolled OIDC client_credentials implementation in
exploit_intelligence/auth.rs with the existing OpenIdTokenProvider and
TokenInjector from common/auth. This eliminates ~140 lines of duplicated
token caching/refresh logic and replaces 7 copy-pasted token injection
blocks with single-line inject_token() calls.

The EI integration now uses OIDC discovery (issuer URL) instead of a raw
token endpoint, and gains TLS insecure support and configurable
refresh-before duration for free.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… queue pattern

Replace the heartbeat-based optimistic locking mechanism with a
visibility-timeout job queue pattern using FOR UPDATE SKIP LOCKED,
matching the approach suggested in review and used by the importer
module.

The POST /analyze handler now only creates a job row and returns
immediately. A background worker loop dequeues eligible jobs
atomically, processes them with no concurrency cap (each job is a
lightweight async poll loop), and handles crash recovery automatically
through expired visibility timeouts.

Key design decisions:
- Short initial visibility window (5 min) extended on each poll tick,
  so long-running analyses (hours) stay claimed while crashed jobs
  are reclaimed quickly
- DEQUEUE_BATCH_SIZE caps per-tick claiming to share work across
  instances via SKIP LOCKED
- Job failure atomically cascades to non-terminal components in a
  single transaction
- Crash-recovery creates missing component records instead of
  silently skipping them
- extend_visibility errors are logged (not silently discarded)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Flatten EI clap args into ExploitIntelligenceArgs struct with
into_config() method. Import namespaces instead of inline paths.
Use SourceDocument::try_into() for storage key construction,
Format::sbom_from_bytes() for content-based format detection,
bytes::Bytes for zero-copy retry, and thiserror for EiRequestError.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Strum355

Copy link
Copy Markdown
Member Author

Ok, I just peeked a bit into the implementation. I think this is a bit overcomplicated. Why not follow a pattern like this: https://aminediro.com/posts/pg_job_queue/

Thanks for this, f272d7d should address this, similar to the importer I believe (but with the visibility timeout approach mentioned in that article instead of the heartbeat approach that importer uses). Some of the style comments were address in the next commit, so you might see some things that should be addressed that have been fixed later 😄 the EI job polling worker runs in the API profile which I hope is okay, outside the initial loading of the SBOM (to check for SBOM type to route the doc to the correct EI endpoint) this process should be fairly lightweight (majority of the time its just polling EI and then ingesting a very small VEX once the work is completed).

Strum355 and others added 2 commits July 16, 2026 13:29
… fix enum casts

Switch single-row status updates from update_many (which requires
explicit CAST for PostgreSQL enum types) to Entity::update(ActiveModel)
which handles enum serialization correctly via DeriveActiveEnum.

Add AllowNotUpdated trait with ok_if_not_updated() to silently handle
status guard mismatches (another worker already transitioned the
record). Add LocalSet wrapper to the worker loop for actix_web::rt
spawn compatibility. Consolidate dequeue_jobs into a single UPDATE
with SQL-level retry_count increment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…error handling and job completion logic

- Add `excluded` boolean column to job components to distinguish
  EI-excluded components from genuinely failed ones
- Add `excluded_components` count to job summary and detail API models
- Mark jobs as completed (not failed) when all components are excluded
  (matches EI service's own semantics)
- EI-side analysis failures (failed/expired poll status) now mark the
  component as failed while the job completes — the orchestration
  succeeded, only the analysis outcome is negative
- Include response body in error messages (truncated, HTML filtered)
  with operation label for debuggability
- Fix ReportError deserialization (EI returns error as object, not string)
- Fix aggregate finding: genuinely failed components produce null finding
  instead of misleading "uncertain"
- Fix test fixtures to use real SBOM records (FK constraint satisfaction)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

5 participants