Skip to content

Commit e999c72

Browse files
github-actions[bot]Marfuenclaude
authored
feat(vendors): refine inherent risk score after research lands posture data (#2760)
The onboarding extraction pass scores vendor inherent risk conservatively from the user's Q&A signals only — it has no posture data, so well-attested vendors get the generic (possible × moderate) default. This adds a follow-up scoring pass that runs once research-vendor has populated GlobalVendors with certifications, subprocessors, type, and trust-page URLs. The per-org Vendor row gets re-scored with that evidence on hand. Components: - New trigger task `score-vendor-risk` (apps/app/src/trigger/tasks/ scrape/score-vendor-risk.ts). Idempotent — fetches the org's Vendor row + the GlobalVendors row by website, calls gpt-4.1-mini with a calibrated prompt that anchors each Likelihood / Impact bucket to attestation criteria (SOC 2 Type II, ISO 27001, ISO 42001, HIPAA, PCI DSS, FedRAMP, etc.), and updates the four inherent + residual fields. Bails early if vendor has no website OR if GlobalVendors hasn't been populated yet. - research-vendor accepts an optional `scoreContext: { vendorId, organizationId }` payload field. After saving GlobalVendors (whether new or existing), it enqueues score-vendor-risk for the per-org Vendor row. Existing-vendor short-circuit also kicks scoring so customers don't get stuck with the extraction default when GlobalVendors was already populated by a previous research run. - triggerVendorResearch (orchestrator) passes `scoreContext` so the bulk onboarding fan-out chains research → scoring per vendor. Net effect: vendors come out of onboarding with a posture-grounded score (e.g. GitHub with SOC 2 + ISO 27001 should land at unlikely × moderate ≈ 3/10) instead of the conservative default. No prompt contains a hardcoded vendor name list — calibration runs entirely off the researched attributes. Co-authored-by: Mariano <marfuen98@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8a1c46f commit e999c72

3 files changed

Lines changed: 215 additions & 2 deletions

File tree

apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,8 +954,16 @@ export async function triggerVendorResearch(vendors: any[]): Promise<void> {
954954
}
955955

956956
try {
957+
// `scoreContext` chains the research run into score-vendor-risk
958+
// when GlobalVendors finishes saving, so the per-org Vendor row
959+
// gets a posture-grounded score instead of the conservative
960+
// (possible × moderate) default the extraction pass set.
957961
const handle = await tasks.trigger<typeof researchVendor>('research-vendor', {
958962
website,
963+
scoreContext:
964+
vendor.id && vendor.organizationId
965+
? { vendorId: vendor.id, organizationId: vendor.organizationId }
966+
: undefined,
959967
});
960968
logger.info(`Triggered research for vendor ${vendor.name} with handle ${handle.id}`);
961969
} catch (error) {

apps/app/src/trigger/tasks/scrape/research.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { researchJobCore } from '@/trigger/lib/research';
22
import { db } from '@db/server';
3-
import { queue, schemaTask } from '@trigger.dev/sdk';
3+
import { logger, queue, schemaTask, tasks } from '@trigger.dev/sdk';
44
import { z } from 'zod';
5+
import type { scoreVendorRisk } from './score-vendor-risk';
56

67
// Each research run can hold a slot for minutes (firecrawl scrape + LLM
78
// extraction). Without a cap, a 10-vendor onboarding can hog the whole
@@ -49,9 +50,37 @@ export const researchVendor = schemaTask({
4950
queue: researchVendorQueue,
5051
schema: z.object({
5152
website: z.string().url(),
53+
/**
54+
* Optional: when set, this task triggers `score-vendor-risk` once the
55+
* GlobalVendors row is saved (or already exists), so the per-org
56+
* Vendor row gets a refined inherent/residual score grounded in the
57+
* researched posture data instead of the conservative default that
58+
* the onboarding extraction pass set.
59+
*/
60+
scoreContext: z
61+
.object({
62+
vendorId: z.string(),
63+
organizationId: z.string(),
64+
})
65+
.optional(),
5266
}),
5367
maxDuration: 1000 * 60 * 10, // 10 minutes total task duration
5468
run: async (payload, { ctx }) => {
69+
const queueScoring = async () => {
70+
if (!payload.scoreContext) return;
71+
try {
72+
await tasks.trigger<typeof scoreVendorRisk>('score-vendor-risk', {
73+
vendorId: payload.scoreContext.vendorId,
74+
organizationId: payload.scoreContext.organizationId,
75+
});
76+
} catch (err) {
77+
logger.error('[research-vendor] failed to enqueue score-vendor-risk', {
78+
err,
79+
...payload.scoreContext,
80+
});
81+
}
82+
};
83+
5584
// Check if vendor already exists
5685
const existingVendor = await db.globalVendors.findFirst({
5786
where: {
@@ -68,13 +97,17 @@ export const researchVendor = schemaTask({
6897
});
6998

7099
if (existingVendor) {
100+
// Even when GlobalVendors is already populated, the per-org Vendor
101+
// row hasn't been scored against that data yet — chain into scoring
102+
// so the customer doesn't get stuck with the extraction default.
103+
await queueScoring();
71104
return {
72105
message: 'Vendor already exists in database',
73106
existingVendor,
74107
};
75108
}
76109

77-
return researchJobCore({
110+
const result = await researchJobCore({
78111
website: payload.website,
79112
prompt: "You're a cyber security researcher, researching a vendor.",
80113
schema: {
@@ -137,5 +170,12 @@ export const researchVendor = schemaTask({
137170
});
138171
},
139172
});
173+
174+
// Now that GlobalVendors is fresh, kick off scoring for the per-org
175+
// Vendor row. Fire-and-forget — the scoring task is idempotent and
176+
// failures shouldn't fail the research run.
177+
await queueScoring();
178+
179+
return result;
140180
},
141181
});
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { openai } from '@ai-sdk/openai';
2+
import { Impact, Likelihood } from '@db';
3+
import { db } from '@db/server';
4+
import { logger, schemaTask } from '@trigger.dev/sdk';
5+
import { generateObject } from 'ai';
6+
import { z } from 'zod';
7+
8+
const ScoreSchema = z.object({
9+
inherent_probability: z.nativeEnum(Likelihood),
10+
inherent_impact: z.nativeEnum(Impact),
11+
residual_probability: z.nativeEnum(Likelihood),
12+
residual_impact: z.nativeEnum(Impact),
13+
rationale: z.string(),
14+
});
15+
16+
/**
17+
* Refine a vendor's inherent + residual risk scores using the data the
18+
* `research-vendor` task gathered into GlobalVendors. The onboarding
19+
* extraction pass scores conservatively from the user's Q&A signals only
20+
* (no posture data), so it lands a lot of well-attested vendors at the
21+
* generic middle. Once research has collected certifications, type, and
22+
* description, we can ground the score in actual evidence and refine.
23+
*
24+
* Idempotent: safe to re-run for the same (vendorId, organizationId)
25+
* — overwrites the four risk fields with the latest LLM verdict.
26+
*/
27+
export const scoreVendorRisk = schemaTask({
28+
id: 'score-vendor-risk',
29+
schema: z.object({
30+
vendorId: z.string(),
31+
organizationId: z.string(),
32+
}),
33+
maxDuration: 60,
34+
retry: { maxAttempts: 2 },
35+
run: async (payload) => {
36+
const { vendorId, organizationId } = payload;
37+
38+
const vendor = await db.vendor.findFirst({
39+
where: { id: vendorId, organizationId },
40+
select: {
41+
id: true,
42+
name: true,
43+
description: true,
44+
category: true,
45+
website: true,
46+
},
47+
});
48+
if (!vendor) {
49+
logger.warn('[score-vendor-risk] vendor not found, skipping', payload);
50+
return { skipped: 'vendor-not-found' as const };
51+
}
52+
if (!vendor.website) {
53+
logger.info('[score-vendor-risk] vendor has no website, skipping', payload);
54+
return { skipped: 'no-website' as const };
55+
}
56+
57+
const globalVendor = await db.globalVendors.findFirst({
58+
where: { website: vendor.website },
59+
select: {
60+
company_description: true,
61+
security_certifications: true,
62+
subprocessors: true,
63+
type_of_company: true,
64+
security_page_url: true,
65+
trust_page_url: true,
66+
},
67+
});
68+
69+
// Without GlobalVendors data we have nothing more than the extraction
70+
// pass already had — re-running the LLM would just burn tokens to
71+
// produce the same answer. Bail.
72+
if (!globalVendor) {
73+
logger.info(
74+
'[score-vendor-risk] no GlobalVendors row yet — research probably still in flight; skipping',
75+
payload,
76+
);
77+
return { skipped: 'no-research-data' as const };
78+
}
79+
80+
const certifications = globalVendor.security_certifications ?? [];
81+
const subprocessors = globalVendor.subprocessors ?? [];
82+
const description = globalVendor.company_description ?? vendor.description ?? '';
83+
const typeOfCompany = globalVendor.type_of_company ?? '';
84+
const hasTrustOrSecurityPage = Boolean(
85+
globalVendor.security_page_url ?? globalVendor.trust_page_url,
86+
);
87+
88+
const promptBlock = [
89+
`Vendor: ${vendor.name}`,
90+
`Category (customer-set): ${vendor.category}`,
91+
typeOfCompany ? `Type (researched): ${typeOfCompany}` : null,
92+
description ? `Description: ${description}` : null,
93+
certifications.length > 0
94+
? `Certifications / attestations (researched): ${certifications.join(', ')}`
95+
: 'Certifications / attestations: none reported',
96+
subprocessors.length > 0
97+
? `Subprocessors (researched): ${subprocessors.join(', ')}`
98+
: null,
99+
hasTrustOrSecurityPage
100+
? 'Vendor publishes a trust portal or security overview page (transparency signal).'
101+
: 'No public trust portal / security overview page found.',
102+
]
103+
.filter(Boolean)
104+
.join('\n');
105+
106+
const { object } = await generateObject({
107+
model: openai('gpt-4.1-mini'),
108+
schema: ScoreSchema,
109+
system: [
110+
'You are scoring inherent vendor risk for a customer that has just listed this vendor as part of their compliance program. Your job is to assign Likelihood and Impact buckets based on the researched data below.',
111+
'',
112+
'inherent_probability — probability of a meaningful security or availability incident at the vendor over a typical 12-month window:',
113+
'- very_unlikely: hyperscaler-tier vendor with multiple top-tier attestations (e.g. SOC 2 Type II + ISO 27001, OR FedRAMP, OR multiple of those) AND clear public transparency.',
114+
'- unlikely: established vendor with at least one strong third-party attestation (SOC 2 Type II, ISO 27001, ISO 42001, HIPAA, PCI DSS Level 1, or equivalent) AND a public security/trust page.',
115+
'- possible: vendor without independent attestation, OR with minor incidents in the last few years, OR limited public posture data. This is the DEFAULT.',
116+
'- likely: vendor with public knowledge of significant security incidents in the last 24 months, OR explicitly no transparency despite handling sensitive data.',
117+
'- very_likely: vendor with chronic / repeated security issues, or essentially unknown posture combined with sensitive-data exposure.',
118+
'',
119+
'inherent_impact — business impact if the vendor is compromised, assuming average customer usage given the vendor\'s category:',
120+
'- insignificant: no PII / no business data / purely cosmetic or public utility.',
121+
'- minor: anonymous metadata only, non-business utilities.',
122+
'- moderate: PII or internal business data, but NOT payments / health / source / auth. DEFAULT for typical SaaS.',
123+
'- major: vendor handles authentication, source code, payments, PHI, or production infrastructure that the customer depends on.',
124+
'- severe: vendor IS the customer\'s production runtime / cloud / single source of truth — compromise means the customer is offline or fundamentally exposed.',
125+
'',
126+
'Scoring rules:',
127+
'1. Read the certification list. ANY of {SOC 2 Type II, ISO 27001, ISO 42001, HIPAA, PCI DSS, FedRAMP, C5, CSA STAR Level 2+} counts as a strong attestation. Multiple of those, especially combined with FedRAMP / hyperscaler-tier scale, drop probability to very_unlikely. A single strong attestation drops probability to unlikely.',
128+
'2. If the certification list is empty, default probability is possible (NOT very_likely). "We don\'t know" is not "definitely bad".',
129+
'3. Use the type and description to set impact. Source-code, payments, auth, infrastructure providers → major. Generic CRM / analytics → moderate. Marketing widgets → minor.',
130+
'4. Residual: default to inherent. Only LOWER residual when the customer has applied their OWN compensating controls (which we don\'t have visibility into here, so usually leave equal).',
131+
'',
132+
'Be specific in the rationale — name a certification, name an attribute. Don\'t recite the rubric.',
133+
].join('\n'),
134+
prompt: promptBlock,
135+
});
136+
137+
await db.vendor.update({
138+
where: { id: vendorId },
139+
data: {
140+
inherentProbability: object.inherent_probability,
141+
inherentImpact: object.inherent_impact,
142+
residualProbability: object.residual_probability,
143+
residualImpact: object.residual_impact,
144+
},
145+
});
146+
147+
logger.info('[score-vendor-risk] scored vendor', {
148+
vendorId,
149+
organizationId,
150+
vendorName: vendor.name,
151+
inherent: `${object.inherent_probability} × ${object.inherent_impact}`,
152+
residual: `${object.residual_probability} × ${object.residual_impact}`,
153+
rationale: object.rationale,
154+
certifications,
155+
});
156+
157+
return {
158+
inherentProbability: object.inherent_probability,
159+
inherentImpact: object.inherent_impact,
160+
residualProbability: object.residual_probability,
161+
residualImpact: object.residual_impact,
162+
rationale: object.rationale,
163+
};
164+
},
165+
});

0 commit comments

Comments
 (0)