Files
sencho/backend/src/services/securityExposureClassification.ts
T
Anso fcd44f5693 fix(security): tie fixable CVE posture to image-update evidence (#1815)
* fix(security): tie fixable CVE posture to image-update evidence

Stop treating Trivy fixed_version alone as an Update affected images CTA. Reuse persisted ImageUpdateService status so Security only offers Review update when an applicable image update is confirmed, and otherwise surfaces waiting or uncertain remediation with truthful affordances.

* fix(security): move image-update recheck helper out of OverviewTab

Satisfy react-refresh/only-export-components so Frontend lint passes.

* fix(security): preserve posture reason image targets in Images drill-down

Carry affected image refs on overview reasons so public exposure and related CTAs open a clearable targeted Images list instead of an unfiltered hunt.

* fix(security): attach Networking exposure intent to posture targets

Preserve stack/service context and intentional classification on network-exposed Security reasons without suppressing risk or claiming Internet reachability.

* fix(security): persist Images exposure intent and triage scope

Standing image summaries carry Networking intent context with cap-safe aggregates, Anatomy Networking links, and scan-sheet triage that defaults to the current image.

* fix(security): clear CI lint errors for exposure helpers

* fix(security): stop intentional exposure from forcing Action needed

Separate exposure fact, intent correctness, and vulnerability drivers so
package fixed_version cannot recreate a permanent public_exposure blocker.

* fix(security): define Monitoring residual-risk narrative

* fix(security): define Secure via residual Crit/High triage

Replace triage-blind raw Crit/High Secure gating with residual material
risk so accepted and ignored stay Monitoring, while not_affected, false
positive, and fixed can clear residual without claiming no detections.

* fix(security): exclude rollback-hold images from Security scans

Hold-only sencho-rb tags are recovery state; keep them out of Trivy node scans, Security inventory, and Overview posture while dual-tagged images remain under their registry tag.

* fix(security): keep authoritative no-update rows after preview

Opening a stack page must not delete ok+false stack_update_status evidence; Security treats a missing row as uncertain and would flip waiting-upstream to unknown.
2026-08-12 15:02:14 -04:00

207 lines
7.4 KiB
TypeScript

/**
* Classifies network-exposed Crit/High images into posture buckets.
*
* Exposure fact (Compose beyond loopback) is separate from exposure correctness
* (intent match) and from Security consequence (KEV, elevated EPSS, confirmed
* image update). Intentional exposure never independently manufactures an
* Action-needed exposure blocker. Package fixed_version alone never recreates
* that blocker through this path.
*/
import { HIGH_EPSS_THRESHOLD, type PostureDriverFinding, type PostureTarget } from './securityPosture';
/** Bounded driver findings attached to vulnerability-derived posture reasons. */
export const POSTURE_DRIVER_CAP = 50;
export interface CappedDrivers {
drivers: PostureDriverFinding[];
/** Full contributing count before the display cap. */
driverCount: number;
driversTruncated: boolean;
}
export function capDriverFindings(drivers: PostureDriverFinding[]): CappedDrivers {
return {
drivers: drivers.slice(0, POSTURE_DRIVER_CAP),
driverCount: drivers.length,
driversTruncated: drivers.length > POSTURE_DRIVER_CAP,
};
}
export type CveIntelLookup = Map<string, { kev?: boolean; epssScore?: number | null }>;
export interface ExposedFindingRow {
vulnerability_id: string;
/** Present when the finding survived suppression filtering as actionable. */
suppressed?: boolean;
}
export interface ClassifyExposedImagesInput {
/** Crit/High findings keyed by image_ref (raw, before suppression). */
critHighByImage: Map<string, ExposedFindingRow[]>;
/** image_ref → true when Compose declares beyond-loopback / host-network. */
exposedMap: Map<string, boolean>;
/** Per-image exposure targets already enriched with Networking intent. */
targetsByImage: Map<string, PostureTarget[]>;
/** Unsuppressed findings per image (caller applies applySuppressions). */
unsuppressedByImage: Map<string, ExposedFindingRow[]>;
intel: CveIntelLookup;
}
export interface ClassifyExposedImagesResult {
/** Distinct exposed images in the Crit/High index (incl. fully suppressed). */
publiclyExposed: number;
/** Intent mismatch (internal/same-node while exposed) with unsuppressed Crit/High. */
exposureIntentConflict: number;
exposureIntentConflictTargets: PostureTarget[];
/** Intent unset/unavailable (not intentional) with unsuppressed Crit/High. Review only. */
exposedUnclassified: number;
exposedUnclassifiedTargets: PostureTarget[];
/**
* Network-exposed images with unsuppressed elevated-EPSS Crit/High.
* Independent of intentional vs unclassified; never uses fixed_version alone.
*/
elevatedExploitRisk: number;
elevatedExploitRiskTargets: PostureTarget[];
elevatedExploitRiskDrivers: PostureDriverFinding[];
elevatedExploitRiskDriverCount: number;
elevatedExploitRiskDriversTruncated: boolean;
}
function isIntentionalTarget(t: PostureTarget): boolean {
return (
t.intentStatus === 'set'
&& !t.intentConflict
&& (t.exposureIntent === 'public'
|| t.exposureIntent === 'lan'
|| t.exposureIntent === 'reverse-proxy'
|| t.exposureIntent === 'temporary')
);
}
/**
* Bucket an image's exposure contexts.
* Conflict wins. Any unset (or only-unavailable / non-intentional) is unclassified.
* All complete contexts intentional → intentional.
*/
export function classifyImageExposureBucket(
targets: PostureTarget[],
): 'conflict' | 'intentional' | 'unclassified' {
if (targets.some((t) => t.intentConflict)) return 'conflict';
if (targets.length === 0) return 'unclassified';
if (targets.some((t) => t.intentStatus === 'unset')) return 'unclassified';
const complete = targets.filter((t) => t.intentStatus !== 'unavailable');
if (complete.length === 0) return 'unclassified';
if (complete.every(isIntentionalTarget)) return 'intentional';
return 'unclassified';
}
function targetKey(t: PostureTarget): string {
return `${t.imageRef}\0${t.stackName ?? ''}\0${t.serviceName ?? ''}`;
}
function pushUniqueTargets(into: PostureTarget[], rows: PostureTarget[]): void {
const seen = new Set(into.map(targetKey));
for (const t of rows) {
const key = targetKey(t);
if (seen.has(key)) continue;
seen.add(key);
into.push(t);
}
}
/**
* Split exposed Crit/High images into conflict / unclassified review / elevated EPSS.
* Intentional exposure contributes only via elevated EPSS (or other independent
* drivers computed outside this function: KEV, confirmed image update).
*/
export function classifyExposedImages(input: ClassifyExposedImagesInput): ClassifyExposedImagesResult {
const {
critHighByImage,
exposedMap,
targetsByImage,
unsuppressedByImage,
intel,
} = input;
let publiclyExposed = 0;
const conflictTargets: PostureTarget[] = [];
const unclassifiedTargets: PostureTarget[] = [];
const elevatedTargets: PostureTarget[] = [];
const elevatedDrivers: PostureDriverFinding[] = [];
const conflictImages = new Set<string>();
const unclassifiedImages = new Set<string>();
const elevatedImages = new Set<string>();
for (const imageRef of critHighByImage.keys()) {
if (exposedMap.get(imageRef) !== true) continue;
publiclyExposed += 1;
const unsuppressed = unsuppressedByImage.get(imageRef) ?? [];
if (unsuppressed.length === 0) continue;
const targets = targetsByImage.get(imageRef) ?? [{ imageRef }];
const bucket = classifyImageExposureBucket(targets);
if (bucket === 'conflict') {
conflictImages.add(imageRef);
pushUniqueTargets(conflictTargets, targets);
} else if (bucket === 'unclassified') {
unclassifiedImages.add(imageRef);
pushUniqueTargets(unclassifiedTargets, targets);
}
let hasHighEpss = false;
for (const e of unsuppressed) {
const epss = intel.get(e.vulnerability_id)?.epssScore ?? 0;
if (epss < HIGH_EPSS_THRESHOLD) continue;
hasHighEpss = true;
elevatedDrivers.push({ vulnerabilityId: e.vulnerability_id, imageRef });
}
if (hasHighEpss) {
elevatedImages.add(imageRef);
pushUniqueTargets(elevatedTargets, targets);
}
}
const elevated = capDriverFindings(elevatedDrivers);
return {
publiclyExposed,
exposureIntentConflict: conflictImages.size,
exposureIntentConflictTargets: conflictTargets,
exposedUnclassified: unclassifiedImages.size,
exposedUnclassifiedTargets: unclassifiedTargets,
elevatedExploitRisk: elevatedImages.size,
elevatedExploitRiskTargets: elevatedTargets,
elevatedExploitRiskDrivers: elevated.drivers,
elevatedExploitRiskDriverCount: elevated.driverCount,
elevatedExploitRiskDriversTruncated: elevated.driversTruncated,
};
}
/** Cap unsuppressed KEV driver rows for posture attachment. */
export function collectKevDrivers(
drivers: Array<{ imageRef: string; vulnerability_id: string; suppressed?: boolean }>,
): CappedDrivers {
const out: PostureDriverFinding[] = [];
for (const e of drivers) {
if (e.suppressed) continue;
out.push({ vulnerabilityId: e.vulnerability_id, imageRef: e.imageRef });
}
return capDriverFindings(out);
}
/** Package-fix finding drivers for the given image refs (order preserved per image list). */
export function collectPackageFixDrivers(
packageFixByImage: Map<string, string[]>,
imageRefs: string[],
): CappedDrivers {
const out: PostureDriverFinding[] = [];
for (const imageRef of imageRefs) {
for (const vulnerabilityId of packageFixByImage.get(imageRef) ?? []) {
out.push({ vulnerabilityId, imageRef });
}
}
return capDriverFindings(out);
}