Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Vulnerability Management report: the report type (#5784 W04). Enum add ONLY,
-- in its own file: a label added by ALTER TYPE cannot be used until the
-- transaction that added it commits, and autoMigrate wraps each file in one
-- transaction (precedent: 2026-10-16-180700-report-type-hardware-lifecycle.sql).
-- No rows are written, so no breeze.scope election is required. Idempotent.
ALTER TYPE report_type ADD VALUE IF NOT EXISTS 'vulnerability_management';

Large diffs are not rendered by default.

17 changes: 11 additions & 6 deletions apps/api/src/db/schema/reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,18 @@ export const reportTypeEnum = pgEnum('report_type', [
// Hardware Lifecycle report: device replacement plan from purchase +
// warranty dates (ported from the LanternOps portal PDF).
'hardware_lifecycle',
// Service-plan evidence W02 (#5784): Huntress incidents for an occurrence's
// period, with an explicit coverage window; see services/threatDetectionReport.ts.
// Service-plan evidence #5784: W02 threat_detection_review (Huntress
// incidents for an occurrence's period, with an explicit coverage window;
// see services/threatDetectionReport.ts), W03 endpoint_management_review
// (over the #5327 M365 Intune sync tables — enrolment coverage, compliance
// breakdown, stale enrolments and licence seats; generated on demand, zero
// new tables), and W04 vulnerability_management (the vulnerability DETAIL
// artifact — findings, exceptions and remediation ranking;
// `security_compliance_posture` keeps its single vulnerability control
// line; neither replaces the other).
'threat_detection_review',
// Endpoint Management Review (#5784 W03): service-plan evidence over the
// #5327 M365 Intune sync tables — enrolment coverage, compliance breakdown,
// stale enrolments and licence seats. Generated on demand; zero new tables.
'endpoint_management_review'
'endpoint_management_review',
'vulnerability_management'
]);

export const reportScheduleEnum = pgEnum('report_schedule', [
Expand Down
35 changes: 35 additions & 0 deletions apps/api/src/routes/reports/schemas.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
threatDetectionConfigFields,
threatDetectionConfigSchema,
updateReportSchema,
vulnerabilityManagementConfigFields,
vulnerabilityManagementConfigSchema,
} from './schemas';

const builderConfig = {
Expand Down Expand Up @@ -130,6 +132,39 @@ describe('report config schema', () => {
expect(parsed.config.includeLicences).toBe(false);
});

it('keeps the vulnerability management persistence fields in sync with the generation schema', () => {
expect(Object.keys(vulnerabilityManagementConfigFields).sort()).toEqual(
Object.keys(vulnerabilityManagementConfigSchema.shape).sort(),
);
});

it('defaults a vulnerability management config to the spec values', () => {
expect(vulnerabilityManagementConfigSchema.parse({})).toEqual({
sites: [], severityFloor: 'high', topN: 25, includeAccepted: true,
});
});

it('rejects an unknown severity floor', () => {
expect(() => vulnerabilityManagementConfigSchema.parse({ severityFloor: 'catastrophic' })).toThrow();
});

it('rejects a topN outside the schema range, for the API caller that bypasses the form', () => {
expect(() => vulnerabilityManagementConfigSchema.parse({ topN: 0 })).toThrow();
expect(() => vulnerabilityManagementConfigSchema.parse({ topN: 501 })).toThrow();
expect(() => vulnerabilityManagementConfigSchema.parse({ topN: 25.5 })).toThrow();
expect(vulnerabilityManagementConfigSchema.parse({ topN: 500 }).topN).toBe(500);
});

it('preserves vulnerability management options on create', () => {
const parsed = createReportSchema.parse({
name: 'Vulns', type: 'vulnerability_management',
config: { severityFloor: 'medium', topN: 50, includeAccepted: false },
});
expect(parsed.config.severityFloor).toBe('medium');
expect(parsed.config.topN).toBe(50);
expect(parsed.config.includeAccepted).toBe(false);
});

it('preserves hardware lifecycle replaceAgeYears on create', () => {
const parsed = createReportSchema.parse({
name: 'Lifecycle', type: 'hardware_lifecycle',
Expand Down
41 changes: 36 additions & 5 deletions apps/api/src/routes/reports/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ export const reportTypeSchema = z.enum([
'threat_detection_review',
// Endpoint Management Review (#5784 W03): Intune posture evidence from the
// #5327 M365 sync tables.
'endpoint_management_review'
'endpoint_management_review',
// #5784 W04: the vulnerability detail artifact (findings, exceptions,
// remediation ranking). Distinct from security_compliance_posture's single
// vulnerability control line.
'vulnerability_management'
]);

/** Report types a human may never create or generate on demand. */
Expand Down Expand Up @@ -157,6 +161,31 @@ export const endpointManagementConfigFields = {
includeLicences: z.boolean().optional(),
};

/**
* Config for the Vulnerability Management report (#5784 W04, spec §3.4).
* `severityFloor` filters the findings sections but NEVER the KEV / high-EPSS
* callouts: an actively exploited medium is a different argument from a
* theoretical critical, and hiding it behind a severity floor is how it gets
* missed. `topN` caps the remediable table; the artifact discloses the number
* withheld rather than truncating silently.
*/
export const vulnerabilityManagementConfigSchema = z.object({
sites: z.array(z.string().guid()).optional().default([]),
severityFloor: z.enum(['critical', 'high', 'medium', 'low']).optional().default('high'),
topN: z.number().int().min(1).max(500).optional().default(25),
includeAccepted: z.boolean().optional().default(true),
});

/** Same keys as `vulnerabilityManagementConfigSchema` without `.default()`s —
* see `securityCompliancePostureConfigFields` for why the two are hand-parallel
* and test-pinned (schemas.config.test.ts). */
export const vulnerabilityManagementConfigFields = {
sites: z.array(z.string().guid()).optional(),
severityFloor: z.enum(['critical', 'high', 'medium', 'low']).optional(),
topN: z.number().int().min(1).max(500).optional(),
includeAccepted: z.boolean().optional(),
};

/**
* Cadence detail + delivery config persisted inside `config`. The builder
* writes these and reportScheduleWorker reads them; they must be declared here
Expand Down Expand Up @@ -201,8 +230,9 @@ const reportConfigFields = {
emailRecipients: z.array(z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/).max(254)).max(50).optional(),
...securityCompliancePostureConfigFields,
...hardwareLifecycleConfigFields,
...threatDetectionConfigFields,
...endpointManagementConfigFields
...threatDetectionConfigFields,
...endpointManagementConfigFields,
...vulnerabilityManagementConfigFields
};

// Loose: the builder round-trips presentation metadata (builderType, dataSource,
Expand Down Expand Up @@ -251,8 +281,9 @@ export const generateReportSchema = z.object({
}).optional(),
...securityCompliancePostureConfigFields,
...hardwareLifecycleConfigFields,
...threatDetectionConfigFields,
...endpointManagementConfigFields
...threatDetectionConfigFields,
...endpointManagementConfigFields,
...vulnerabilityManagementConfigFields
}).optional().default({}),
format: z.enum(['csv', 'pdf', 'excel']).default('csv'),
orgId: z.string().guid().optional()
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/services/managedEvidenceRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,14 @@ export const MANAGED_EVIDENCE_REGISTRY = Object.freeze({
definitionName: `${MANAGED_EVIDENCE_DEFINITION_NAME_PREFIX}Endpoint management review`,
defaultConfig: { sites: [], staleEnrolmentDays: 14, trendDays: 30, includeLicences: true },
},
// W04 adds 'vulnerability_management'.
// #5784 W04. The vulnerability DETAIL artifact. Config keys are the spec's
// (§3.4) and are spelled identically in `vulnerabilityManagementConfigSchema`
// and the portal `PORTAL_DEFINITIONS` entry.
vulnerability_management: {
type: 'vulnerability_management',
definitionName: `${MANAGED_EVIDENCE_DEFINITION_NAME_PREFIX}Vulnerability management`,
defaultConfig: { sites: [], severityFloor: 'high', topN: 25, includeAccepted: true },
},
// W06 adds 'identity_access_review'.
} as const satisfies Readonly<Record<string, ManagedEvidenceEntry>>);

Expand Down
20 changes: 20 additions & 0 deletions apps/api/src/services/portal/reportsSelfService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ describe('provisionPortalReportDefinitions', () => {
{ type: 'hardware_lifecycle' },
{ type: 'threat_detection_review' },
{ type: 'endpoint_management_review' },
{ type: 'vulnerability_management' },
]);
state.insertReturning.mockResolvedValue([]);
state.updateReturning.mockResolvedValue([]);
Expand Down Expand Up @@ -222,6 +223,19 @@ describe('provisionPortalReportDefinitions', () => {
executionScopeUserId: USER_ID,
executionScopePrincipalKind: 'user',
}),
// #5784 W04 — provisioned, but never portal-generatable.
expect.objectContaining({
orgId: ORG_ID,
name: 'Service evidence — Vulnerability management',
type: 'vulnerability_management',
schedule: 'one_time',
format: 'pdf',
portalSelfService: true,
createdBy: USER_ID,
executionScopeKind: 'unrestricted',
executionScopeUserId: USER_ID,
executionScopePrincipalKind: 'user',
}),
]);
expect(state.conflict).toHaveBeenCalledOnce();
});
Expand Down Expand Up @@ -381,6 +395,12 @@ describe('PORTAL_REPORT_TYPES', () => {
it('keeps endpoint_management_review OUT of the portal generate allowlist', () => {
expect(PORTAL_REPORT_TYPES).not.toContain('endpoint_management_review');
});

// #5784 W04, OD-10 = A. Being provisioned as a definition is NOT being
// self-servable: a portal user must never be able to run this on demand.
it('keeps vulnerability_management OUT of the portal generate allowlist', () => {
expect(PORTAL_REPORT_TYPES).not.toContain('vulnerability_management');
});
});

describe('hardware_lifecycle MSP config inheritance (decision B2)', () => {
Expand Down
19 changes: 18 additions & 1 deletion apps/api/src/services/portal/reportsSelfService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,23 @@ const PORTAL_DEFINITIONS = [
includeLicences: true,
},
},
// #5784 W04 — managed evidence, NOT self-service. Provisioned here so an org
// that already enabled portal reports has the definition ready (and so
// `resolveManagedEvidenceDefinition` adopts it rather than racing to create
// it), but deliberately ABSENT from PORTAL_REPORT_TYPES: a portal user can
// never generate it, and a run only becomes visible when the deliverable
// occurrence is delivered (OD-12). Name and config are spelled identically
// to MANAGED_EVIDENCE_REGISTRY's entry.
{
type: 'vulnerability_management',
name: 'Service evidence — Vulnerability management',
config: {
sites: [],
severityFloor: 'high',
topN: 25,
includeAccepted: true,
},
},
] as const;

/** Exported for `managedEvidenceRegistry.test.ts`, which pins this array
Expand Down Expand Up @@ -390,7 +407,7 @@ function toDto(row: {
// type filter, so a managed-evidence run of a type outside the three
// self-service ones legitimately flows through here. Typing it as
// PortalReportType was a lie the compiler could not see, because the value
// comes from the database (#5784 W02, W03).
// comes from the database (#5784 W02, W03, W04).
type: PortalRunDto['type'];
status: 'pending' | 'running' | 'completed' | 'failed';
startedAt: Date | null;
Expand Down
34 changes: 34 additions & 0 deletions apps/api/src/services/reportGenerationService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ vi.mock('../db', () => ({
db: {
select: vi.fn(),
},
// #5784 W04: vulnerability_management's shared loader elevates the GLOBAL CVE
// catalog read out of the request's org context, so the parameterized arms
// below reach these two. They pass the callback straight through — the site
// scope under test is bound by the DEVICE query, not by the context helper.
runOutsideDbContext: vi.fn((fn: () => unknown) => fn()),
withSystemDbAccessContext: vi.fn((fn: () => unknown) => fn()),
}));

import { db } from '../db';
Expand Down Expand Up @@ -35,6 +41,7 @@ const REPORT_TYPES: readonly ReportType[] = [
'hardware_lifecycle',
'threat_detection_review',
'endpoint_management_review',
'vulnerability_management',
];
/** Every `ReportType` that is NOT generated on demand. P2-3 added the first
* one: a weekly AI narrative's artifact is written once by the agent run and
Expand Down Expand Up @@ -149,6 +156,33 @@ describe('generateReport mandatory execution authority', () => {
},
);

// #5784 W04. This arm must NOT be the bare `emptyRowsReport()` the other
// row-shaped types get: a summary-less result falls through buildReportPdf's
// arm to renderGenericReport, whose "No data available for the selected
// filters." is indistinguishable from "we checked every device and found
// none". Nothing was queried, so the counts are UNMEASURED, and the artifact
// has to say which of the two happened.
it('vulnerability_management returns a shaped, unmeasured summary for restricted-empty, not a bare empty result', async () => {
const result = await generateReport(
'vulnerability_management',
ORG_ID,
{},
authority('restricted', []),
);

expect(db.select).not.toHaveBeenCalled();
const summary = result.summary as {
open?: { critical: number | null; knownExploited: number | null };
dataGaps?: string[];
closedThisPeriod?: { count: number | null };
};
expect(summary).toBeTruthy();
expect(summary.open?.critical).toBeNull();
expect(summary.open?.knownExploited).toBeNull();
expect(summary.closedThisPeriod?.count).toBeNull();
expect(summary.dataGaps?.join(' ')).toMatch(/no sites in scope/i);
});

it.each(['executive_summary', 'security_compliance_posture', 'hardware_lifecycle'] as const)(
'allows portal-user authority for %s',
async (type) => {
Expand Down
34 changes: 33 additions & 1 deletion apps/api/src/services/reportGenerationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
reportRuns
} from '../db/schema';
import type { ExecutiveSummary } from '@breeze/shared';
import { emptyVulnerabilityManagementSummary } from '@breeze/shared';
import {
ENDPOINT_MANAGEMENT_NO_SITES_GAP,
emptyEndpointManagementSummary,
Expand Down Expand Up @@ -64,7 +65,12 @@ export type ReportType =
// posture from the #5327 sync tables, with the freshness of each domain
// printed. Current inventory plus rollup trend only — entity-level history is
// not reconstructible (see services/endpointManagementReport.ts).
| 'endpoint_management_review';
| 'endpoint_management_review'
// #5784 W04. Service-plan evidence: the vulnerability DETAIL artifact.
// security_compliance_posture keeps its single control line; this is the
// findings, exceptions and remediation ranking a vulnerability-management
// deliverable needs. See services/vulnerabilityManagementReport.ts.
| 'vulnerability_management';

/**
* Thrown by every generation entry point for a `ReportType` whose artifact is
Expand Down Expand Up @@ -920,6 +926,12 @@ async function dispatchReportGeneration(
const { generateEndpointManagementReport } = await import('./endpointManagementReport');
return generateEndpointManagementReport(orgId, config, authority, evidence);
}
// #5784 W04. The dynamic import keeps a heavy generator out of the hot path
// and avoids the module cycle back to `assertReportExecutionPreflight`.
case 'vulnerability_management': {
const { generateVulnerabilityManagementReport } = await import('./vulnerabilityManagementReport');
return generateVulnerabilityManagementReport(orgId, config, authority, evidence);
}
default: {
const exhaustive: never = type;
throw new Error(`Invalid report type: ${String(exhaustive)}`);
Expand Down Expand Up @@ -1011,6 +1023,26 @@ function zeroSafeReport(type: ReportType, orgId: string): ReportResult {
dataGap: ENDPOINT_MANAGEMENT_NO_SITES_GAP,
}) as unknown as Record<string, unknown>,
};
// #5784 W04. NOT `emptyRowsReport()`: that returns no `summary`, and
// `buildReportPdf`'s vulnerability_management arm requires one — a
// summary-less result falls through to `renderGenericReport`, which prints
// "No data available for the selected filters.", phrasing indistinguishable
// from "we checked every device and found none". A site-restricted
// authority with zero sites queried nothing, so the counts are NOT
// MEASURED and the artifact says which of the two happened.
case 'vulnerability_management': {
const generatedAt = new Date().toISOString();
return {
rows: [],
rowCount: 0,
generatedAt,
summary: emptyVulnerabilityManagementSummary(
orgId,
generatedAt,
'This report ran under a site-restricted authority with no sites in scope, so no device was queried. The counts below are not measured — they are not zero.',
) as unknown as Record<string, unknown>,
};
}
// P2-3 (#4190) — refused HERE too, not only in the dispatch switch above.
// A restricted-empty authority short-circuits into this function before
// dispatch ever runs, and an empty zero-safe shape would read as "the
Expand Down
Loading
Loading