feat: add exploit-intelligence job polling, configuration & API endpoints#2464
feat: add exploit-intelligence job polling, configuration & API endpoints#2464Strum355 wants to merge 25 commits into
Conversation
There was a problem hiding this comment.
Sorry @Strum355, your pull request is larger than the review limit of 150000 diff characters
|
Please consider that this code can run multiple times on the same database, in multiple instances. |
ruromero
left a comment
There was a problem hiding this comment.
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.
| /// | ||
| /// Updates both the component record (with finding/advisory) and the parent | ||
| /// job record (status only) upon completion. | ||
| pub(crate) async fn poll_for_result( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
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 🙂 |
There was a problem hiding this comment.
Sorry @Strum355, your pull request is larger than the review limit of 150000 diff characters
723c26e to
79c41bb
Compare
| (status = 503, description = "Exploit Intelligence service not configured"), | ||
| ), | ||
| )] | ||
| #[post("/v3/exploit-intelligence/analyze")] |
There was a problem hiding this comment.
Not sure we're using that pattern so far. Maybe check.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
If EI is not available, how do we ensure those endpoints are not present?
There was a problem hiding this comment.
Or, what's our strategy here?
There was a problem hiding this comment.
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
9155eea to
9268edd
Compare
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>
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). |
… 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>
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 statusrunner/— background processing: SBOM upload with retry, polling for results, orphaned job recovery, heartbeat coordinationservice/— CRUD operations on job and component entities, recovery queriesmodel/— request/response DTOs and API modelsauth.rs— OIDC client credentials and static token authentication for the EI serviceei_client.rs— response type definitions for the EI client APIAPI 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 bysbom_idandstatusGET /v3/exploit-intelligence/jobs/{id}— job details including per-component findings, advisory links, and report URLsAnalysis flows:
/api/v1/reports/by-scan-id/{scanId}→ extract finding → ingest VEX if available/api/v1/products/{productId}→ fetch per-component reports → extract findings and ingest VEX documents individuallyRetry and polling:
backoncrate (already a transitive dependency viawalker-common) with configurable exponential backoffbackon::ConstantBuilderwith aCell-based consecutive failure counter for distinguishing transient errors from permanent failuresMulti-instance safety:
heartbeatcolumn (nanosecond timestamp) on the job table serves as a CAS lock —UPDATE ... WHERE heartbeat = <expected>ensures mutual exclusion.SELECT ... FOR UPDATE SKIP LOCKEDduring startup recovery atomically claims orphaned jobsCancellationTokenfor cooperative work cancellation when the heartbeat is lostupdate_job_completed,update_job_failed) use CAS against the heartbeat value — only the owning instance can transition job state(sbom_id, vulnerability_id) WHERE status IN ('pending', 'running')prevents duplicate active jobs at the database levelEI-generated advisory labeling:
source: exploit-intelligenceto distinguish AI-generated findings from vendor-published advisoriesConfiguration (environment variables):
EXPLOIT_INTELLIGENCE_URLEXPLOIT_INTELLIGENCE_UI_URLEXPLOIT_INTELLIGENCE_POLL_INTERVAL30sEXPLOIT_INTELLIGENCE_MAX_POLL_DURATION30mEXPLOIT_INTELLIGENCE_UPLOAD_MAX_RETRIES3EXPLOIT_INTELLIGENCE_UPLOAD_RETRY_DELAY1sEXPLOIT_INTELLIGENCE_MAX_CONSECUTIVE_POLL_FAILURES5EXPLOIT_INTELLIGENCE_AUTH_TOKENEXPLOIT_INTELLIGENCE_OIDC_TOKEN_URLEXPLOIT_INTELLIGENCE_OIDC_CLIENT_IDEXPLOIT_INTELLIGENCE_OIDC_CLIENT_SECRETMigration changes (
m0002230):heartbeat DECIMAL NULLcolumn toexploit_intelligence_jobtableidx_ei_job_active_dedupon(sbom_id, vulnerability_id)for active jobsDependencies
backonas a direct workspace dependency (already transitive viawalker-common)humantimeto the server crate for duration configurationtokio-utilto fundamental forCancellationToken