Skip to content

Commit 6eb78ff

Browse files
authored
Merge pull request #2869 from trycompai/main
[comp] Production Deploy
2 parents 65f0138 + 12c907b commit 6eb78ff

19 files changed

Lines changed: 615 additions & 148 deletions

File tree

‎apps/api/src/admin-organizations/admin-findings.controller.spec.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ describe('AdminFindingsController', () => {
3636
findByOrganizationId: jest.fn(),
3737
create: jest.fn(),
3838
update: jest.fn(),
39+
delete: jest.fn(),
3940
};
4041

4142
beforeEach(async () => {
@@ -122,4 +123,26 @@ describe('AdminFindingsController', () => {
122123
expect(result).toEqual(updated);
123124
});
124125
});
126+
127+
describe('remove', () => {
128+
it('should delete a finding as platform admin', async () => {
129+
const deleted = {
130+
message: 'Finding deleted successfully',
131+
deletedFinding: { id: 'fnd_1' },
132+
};
133+
mockService.delete.mockResolvedValue(deleted);
134+
135+
const result = await controller.remove('org_1', 'fnd_1', {
136+
userId: 'usr_admin',
137+
});
138+
139+
expect(mockService.delete).toHaveBeenCalledWith(
140+
'org_1',
141+
'fnd_1',
142+
'usr_admin',
143+
null,
144+
);
145+
expect(result).toEqual(deleted);
146+
});
147+
});
125148
});

‎apps/api/src/admin-organizations/admin-findings.controller.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
Controller,
3+
Delete,
34
Get,
45
Post,
56
Patch,
@@ -92,4 +93,14 @@ export class AdminFindingsController {
9293
null,
9394
);
9495
}
96+
97+
@Delete(':orgId/findings/:findingId')
98+
@ApiOperation({ summary: 'Delete a finding for an organization (admin)' })
99+
async remove(
100+
@Param('orgId') orgId: string,
101+
@Param('findingId') findingId: string,
102+
@Req() req: AdminRequest,
103+
) {
104+
return this.findingsService.delete(orgId, findingId, req.userId, null);
105+
}
95106
}

‎apps/api/src/audit/audit-log.constants.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export const COMMENT_ENTITY_TYPE_MAP: Record<string, AuditLogEntityType> = {
6161
[CommentEntityType.vendor]: AuditLogEntityType.vendor,
6262
[CommentEntityType.risk]: AuditLogEntityType.risk,
6363
[CommentEntityType.policy]: AuditLogEntityType.policy,
64+
[CommentEntityType.finding]: AuditLogEntityType.finding,
6465
};
6566

6667
// Fields that reference the member table and should be resolved to user names.

‎apps/api/src/comments/comment-mention-notifier.service.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,27 @@ async function buildFallbackCommentContext(params: {
174174
};
175175
}
176176

177+
if (entityType === CommentEntityType.finding) {
178+
const finding = await db.finding.findFirst({
179+
where: { id: entityId, organizationId },
180+
select: { content: true },
181+
});
182+
183+
if (!finding) {
184+
return null;
185+
}
186+
187+
const url = new URL(`${appUrl}/${organizationId}/overview/findings`);
188+
url.searchParams.set('open', entityId);
189+
190+
const snippet = finding.content?.trim().split('\n')[0]?.slice(0, 80);
191+
return {
192+
entityName: snippet || 'Finding',
193+
entityRoutePath: 'overview/findings',
194+
commentUrl: url.toString(),
195+
};
196+
}
197+
177198
// CommentEntityType.policy
178199
const policy = await db.policy.findFirst({
179200
where: { id: entityId, organizationId },

‎apps/api/src/comments/comments.service.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,14 @@ export class CommentsService {
120120
break;
121121
}
122122

123+
case CommentEntityType.finding: {
124+
const finding = await db.finding.findFirst({
125+
where: { id: entityId, organizationId },
126+
});
127+
entityExists = !!finding;
128+
break;
129+
}
130+
123131
default:
124132
throw new BadRequestException(`Unsupported entity type: ${entityType}`);
125133
}

‎apps/api/src/findings/findings.service.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,7 @@ export class FindingsService {
486486
organizationId: string,
487487
findingId: string,
488488
userId: string,
489-
memberId: string,
489+
memberId: string | null,
490490
) {
491491
const finding = await this.findById(organizationId, findingId);
492492

‎apps/api/src/utils/file-type-validation.spec.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,59 @@ describe('validateFileContent', () => {
9191
).toThrow(BadRequestException);
9292
});
9393

94+
it('should allow prose mentioning "JavaScript:" in text content', () => {
95+
const jsonBuffer = Buffer.from(
96+
'{"summary":"JavaScript: zod; Python: pydantic"}',
97+
);
98+
expect(() =>
99+
validateFileContent(jsonBuffer, 'application/json', 'report.json'),
100+
).not.toThrow();
101+
});
102+
103+
it('should still reject javascript: URL schemes', () => {
104+
const malicious = Buffer.from('<a href="javascript:alert(1)">x</a>');
105+
expect(() =>
106+
validateFileContent(malicious, 'text/html', 'evil.html'),
107+
).toThrow(BadRequestException);
108+
});
109+
110+
it('should still reject vbscript: URL schemes', () => {
111+
const malicious = Buffer.from('<a href="vbscript:msgbox(1)">x</a>');
112+
expect(() =>
113+
validateFileContent(malicious, 'text/html', 'evil.html'),
114+
).toThrow(BadRequestException);
115+
});
116+
117+
it('should reject javascript: URLs with whitespace after the colon', () => {
118+
const malicious = Buffer.from('<a href="javascript: alert(1)">x</a>');
119+
expect(() =>
120+
validateFileContent(malicious, 'text/html', 'evil.html'),
121+
).toThrow(BadRequestException);
122+
});
123+
124+
it('should reject javascript: in single-quoted attributes', () => {
125+
const malicious = Buffer.from("<a href='javascript:alert(1)'>x</a>");
126+
expect(() =>
127+
validateFileContent(malicious, 'text/html', 'evil.html'),
128+
).toThrow(BadRequestException);
129+
});
130+
131+
it('should reject javascript: in svg xlink:href', () => {
132+
const malicious = Buffer.from(
133+
'<svg><a xlink:href="javascript:alert(1)">x</a></svg>',
134+
);
135+
expect(() =>
136+
validateFileContent(malicious, 'image/svg+xml', 'evil.svg'),
137+
).toThrow(BadRequestException);
138+
});
139+
140+
it('should allow prose mentioning "javascript:" outside attribute context', () => {
141+
const docs = Buffer.from('See the javascript: URL scheme documentation.');
142+
expect(() =>
143+
validateFileContent(docs, 'text/plain', 'notes.txt'),
144+
).not.toThrow();
145+
});
146+
94147
it('should reject a RIFF file with script content disguised as WebP', () => {
95148
const malicious = Buffer.alloc(64);
96149
malicious.write('RIFF', 0);

‎apps/api/src/utils/file-type-validation.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ const DANGEROUS_CONTENT_PATTERNS = [
3333
/<iframe[\s>]/i,
3434
/<object[\s>]/i,
3535
/<embed[\s>]/i,
36-
/javascript:/i,
37-
/vbscript:/i,
36+
/(?:href|src|action|formaction|xlink:href|content|poster|background|data)\s*=\s*["']?\s*javascript:/i,
37+
/(?:href|src|action|formaction|xlink:href|content|poster|background|data)\s*=\s*["']?\s*vbscript:/i,
3838
/\bon(?:click|load|error|mouseover|focus|blur|submit|change|input|keydown|keyup|mousedown|mouseup|dblclick|contextmenu|drag|drop|touchstart|touchend|pointerdown|pointerup|animationend|abort|beforeunload|unload)\s*=/i,
3939
];
4040

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
'use client';
2+
3+
import {
4+
Badge,
5+
DropdownMenu,
6+
DropdownMenuContent,
7+
DropdownMenuItem,
8+
DropdownMenuTrigger,
9+
Select,
10+
SelectContent,
11+
SelectItem,
12+
SelectTrigger,
13+
TableCell,
14+
TableRow,
15+
Text,
16+
} from '@trycompai/design-system';
17+
import { Edit, OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons';
18+
19+
export interface AdminFinding {
20+
id: string;
21+
type: string;
22+
status: string;
23+
severity: string;
24+
content: string;
25+
area: string | null;
26+
createdAt: string;
27+
createdBy?: { user?: { name: string; email: string } } | null;
28+
createdByAdmin?: { name: string; email: string } | null;
29+
task?: { id: string; title: string } | null;
30+
evidenceSubmission?: { id: string; formType: string } | null;
31+
evidenceFormType?: string | null;
32+
policy?: { id: string; name: string } | null;
33+
vendor?: { id: string; name: string } | null;
34+
risk?: { id: string; title: string } | null;
35+
member?: { id: string; user: { name: string; email: string } } | null;
36+
device?: { id: string; name: string; hostname: string } | null;
37+
}
38+
39+
const STATUS_OPTIONS = ['open', 'ready_for_review', 'needs_revision', 'closed'];
40+
41+
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
42+
open: 'destructive',
43+
ready_for_review: 'outline',
44+
needs_revision: 'secondary',
45+
closed: 'default',
46+
};
47+
48+
const SEVERITY_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
49+
low: 'outline',
50+
medium: 'secondary',
51+
high: 'secondary',
52+
critical: 'destructive',
53+
};
54+
55+
function formatStatus(status: string) {
56+
return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
57+
}
58+
59+
function getCreatorName(finding: AdminFinding): string {
60+
return (
61+
finding.createdBy?.user?.name ||
62+
finding.createdBy?.user?.email ||
63+
finding.createdByAdmin?.name ||
64+
finding.createdByAdmin?.email ||
65+
'Unknown'
66+
);
67+
}
68+
69+
export function getTargetLabel(f: AdminFinding): string {
70+
if (f.task) return `Task: ${f.task.title}`;
71+
if (f.policy) return `Policy: ${f.policy.name}`;
72+
if (f.vendor) return `Vendor: ${f.vendor.name}`;
73+
if (f.risk) return `Risk: ${f.risk.title}`;
74+
if (f.member) return `Person: ${f.member.user.name || f.member.user.email}`;
75+
if (f.device) return `Device: ${f.device.name || f.device.hostname}`;
76+
if (f.evidenceSubmission) return `Evidence: ${f.evidenceSubmission.formType}`;
77+
if (f.evidenceFormType) return `Form: ${f.evidenceFormType}`;
78+
if (f.area) return `Area: ${f.area}`;
79+
return '—';
80+
}
81+
82+
interface AdminFindingRowProps {
83+
finding: AdminFinding;
84+
statusUpdating: boolean;
85+
onStatusChange: (findingId: string, newStatus: string) => void;
86+
onEdit: (finding: AdminFinding) => void;
87+
onDelete: (finding: AdminFinding) => void;
88+
}
89+
90+
export function AdminFindingRow({
91+
finding,
92+
statusUpdating,
93+
onStatusChange,
94+
onEdit,
95+
onDelete,
96+
}: AdminFindingRowProps) {
97+
return (
98+
<TableRow>
99+
<TableCell>
100+
<div className="max-w-[400px] truncate">
101+
<Text size="sm">{finding.content}</Text>
102+
</div>
103+
</TableCell>
104+
<TableCell>
105+
<Text size="sm" variant="muted">
106+
{getTargetLabel(finding)}
107+
</Text>
108+
</TableCell>
109+
<TableCell>
110+
<Badge variant={SEVERITY_VARIANT[finding.severity] ?? 'secondary'}>
111+
{finding.severity}
112+
</Badge>
113+
</TableCell>
114+
<TableCell>
115+
<Text size="sm" variant="muted">
116+
{getCreatorName(finding)}
117+
</Text>
118+
</TableCell>
119+
<TableCell>
120+
<Select
121+
value={finding.status}
122+
onValueChange={(val) => {
123+
if (val) onStatusChange(finding.id, val);
124+
}}
125+
disabled={statusUpdating}
126+
>
127+
<SelectTrigger size="sm">
128+
<Badge variant={STATUS_VARIANT[finding.status] ?? 'default'}>
129+
{formatStatus(finding.status)}
130+
</Badge>
131+
</SelectTrigger>
132+
<SelectContent alignItemWithTrigger={false}>
133+
{STATUS_OPTIONS.map((s) => (
134+
<SelectItem key={s} value={s}>
135+
{formatStatus(s)}
136+
</SelectItem>
137+
))}
138+
</SelectContent>
139+
</Select>
140+
</TableCell>
141+
<TableCell>
142+
<div className="flex justify-center">
143+
<DropdownMenu>
144+
<DropdownMenuTrigger
145+
aria-label="Finding actions"
146+
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
147+
>
148+
<OverflowMenuVertical />
149+
</DropdownMenuTrigger>
150+
<DropdownMenuContent align="end">
151+
<DropdownMenuItem onClick={() => onEdit(finding)}>
152+
<Edit size={16} className="mr-2" />
153+
<span>Edit</span>
154+
</DropdownMenuItem>
155+
<DropdownMenuItem
156+
variant="destructive"
157+
onClick={() => onDelete(finding)}
158+
>
159+
<TrashCan size={16} className="mr-2" />
160+
<span>Delete</span>
161+
</DropdownMenuItem>
162+
</DropdownMenuContent>
163+
</DropdownMenu>
164+
</div>
165+
</TableCell>
166+
</TableRow>
167+
);
168+
}

0 commit comments

Comments
 (0)