Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
42357e3
fix(db): add finding to CommentEntityType
chasprowebdev May 12, 2026
08d314c
fix(api): update comment endpoint to support finding entityType
chasprowebdev May 12, 2026
b8b5874
feat(app): add comments directly to findings
chasprowebdev May 12, 2026
1d3d714
Merge branch 'main' into chas/add-comments-to-findings
chasprowebdev May 13, 2026
c8a91e0
fix(api): add an endpoint to delete the finding as platform admin
chasprowebdev May 13, 2026
7dac1e8
feat(app): add ability to edit/delete findings from admin dashboard
chasprowebdev May 13, 2026
7c20e4d
Merge branch 'main' of https://github.com/trycompai/comp into chas/ed…
chasprowebdev May 13, 2026
8b6a665
fix(app): keep delete-finding dialog mounted while the request is in …
chasprowebdev May 13, 2026
9e865b7
Merge branch 'main' into chas/edit-delete-finding-in-admin
tofikwest May 13, 2026
3df5115
fix(app): show comments on task overview
chasprowebdev May 14, 2026
869681f
Merge branch 'main' of https://github.com/trycompai/comp into chas/sh…
chasprowebdev May 14, 2026
aa7a2d5
Merge branch 'main' into chas/edit-delete-finding-in-admin
chasprowebdev May 14, 2026
07a1243
fix(api): tighten javascript:/vbscript: regex to require non-whitespa…
chasprowebdev May 15, 2026
634d95b
fix(api): anchor javascript:/vbscript: detection to URL attribute con…
chasprowebdev May 15, 2026
f6272e8
Merge branch 'main' of https://github.com/trycompai/comp into chas/js…
chasprowebdev May 15, 2026
36ba9d2
fix(app): remove 'evidence submission' from adding finding box
chasprowebdev May 18, 2026
1f346e1
chore: merge release v3.57.0 back to main [skip ci]
github-actions[bot] May 19, 2026
ae06acf
fix(cloud-tests): keep findings and history caches in sync on mark/re…
tofikwest May 19, 2026
80bfd82
Merge pull request #2868 from trycompai/tofik/exception-history-refresh
tofikwest May 19, 2026
f7598a1
Merge branch 'main' into chas/show-comments-on-task-overview
tofikwest May 19, 2026
b3b27e4
Merge branch 'main' into chas/edit-delete-finding-in-admin
tofikwest May 19, 2026
2c2f170
Merge branch 'main' into chas/json-upload-issue
tofikwest May 19, 2026
0382bd9
Merge pull request #2842 from trycompai/chas/show-comments-on-task-ov…
tofikwest May 19, 2026
93b28be
Merge branch 'main' into chas/edit-delete-finding-in-admin
tofikwest May 19, 2026
c42c9a1
Merge branch 'main' into chas/json-upload-issue
tofikwest May 19, 2026
4e44003
Merge pull request #2833 from trycompai/chas/edit-delete-finding-in-a…
tofikwest May 19, 2026
1434d4b
Merge branch 'main' into chas/json-upload-issue
tofikwest May 19, 2026
7a96f39
Merge pull request #2858 from trycompai/chas/json-upload-issue
tofikwest May 19, 2026
eaa83aa
Merge branch 'main' of https://github.com/trycompai/comp into chas/ad…
chasprowebdev May 19, 2026
d389a4a
Merge branch 'main' into chas/remove-evidence-from-creating-finding
chasprowebdev May 19, 2026
4611e48
fix(app): allow admin & owner to remove the device
chasprowebdev May 19, 2026
4bfa230
Merge pull request #2870 from trycompai/chas/remove-device-permission
tofikwest May 19, 2026
61f577a
Merge branch 'main' into chas/remove-evidence-from-creating-finding
tofikwest May 19, 2026
2121ae0
Merge pull request #2865 from trycompai/chas/remove-evidence-from-cre…
tofikwest May 19, 2026
1dd4f40
Merge branch 'main' into chas/add-comments-to-findings
tofikwest May 19, 2026
9191cd9
Merge pull request #2827 from trycompai/chas/add-comments-to-findings
tofikwest May 19, 2026
2c6267d
fix(db): remove duplicate add-finding-to-CommentEntityType migration
tofikwest May 19, 2026
12c907b
Merge pull request #2872 from trycompai/tofik/fix-duplicate-finding-e…
tofikwest May 19, 2026
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
23 changes: 23 additions & 0 deletions apps/api/src/admin-organizations/admin-findings.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ describe('AdminFindingsController', () => {
findByOrganizationId: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};

beforeEach(async () => {
Expand Down Expand Up @@ -122,4 +123,26 @@ describe('AdminFindingsController', () => {
expect(result).toEqual(updated);
});
});

describe('remove', () => {
it('should delete a finding as platform admin', async () => {
const deleted = {
message: 'Finding deleted successfully',
deletedFinding: { id: 'fnd_1' },
};
mockService.delete.mockResolvedValue(deleted);

const result = await controller.remove('org_1', 'fnd_1', {
userId: 'usr_admin',
});

expect(mockService.delete).toHaveBeenCalledWith(
'org_1',
'fnd_1',
'usr_admin',
null,
);
expect(result).toEqual(deleted);
});
});
});
11 changes: 11 additions & 0 deletions apps/api/src/admin-organizations/admin-findings.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
Controller,
Delete,
Get,
Post,
Patch,
Expand Down Expand Up @@ -92,4 +93,14 @@ export class AdminFindingsController {
null,
);
}

@Delete(':orgId/findings/:findingId')
@ApiOperation({ summary: 'Delete a finding for an organization (admin)' })
async remove(
@Param('orgId') orgId: string,
@Param('findingId') findingId: string,
@Req() req: AdminRequest,
) {
return this.findingsService.delete(orgId, findingId, req.userId, null);
}
}
1 change: 1 addition & 0 deletions apps/api/src/audit/audit-log.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const COMMENT_ENTITY_TYPE_MAP: Record<string, AuditLogEntityType> = {
[CommentEntityType.vendor]: AuditLogEntityType.vendor,
[CommentEntityType.risk]: AuditLogEntityType.risk,
[CommentEntityType.policy]: AuditLogEntityType.policy,
[CommentEntityType.finding]: AuditLogEntityType.finding,
};

// Fields that reference the member table and should be resolved to user names.
Expand Down
21 changes: 21 additions & 0 deletions apps/api/src/comments/comment-mention-notifier.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,27 @@ async function buildFallbackCommentContext(params: {
};
}

if (entityType === CommentEntityType.finding) {
const finding = await db.finding.findFirst({
where: { id: entityId, organizationId },
select: { content: true },
});

if (!finding) {
return null;
}

const url = new URL(`${appUrl}/${organizationId}/overview/findings`);
url.searchParams.set('open', entityId);

const snippet = finding.content?.trim().split('\n')[0]?.slice(0, 80);
return {
entityName: snippet || 'Finding',
entityRoutePath: 'overview/findings',
commentUrl: url.toString(),
};
}

// CommentEntityType.policy
const policy = await db.policy.findFirst({
where: { id: entityId, organizationId },
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/comments/comments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ export class CommentsService {
break;
}

case CommentEntityType.finding: {
const finding = await db.finding.findFirst({
where: { id: entityId, organizationId },
});
entityExists = !!finding;
break;
}

default:
throw new BadRequestException(`Unsupported entity type: ${entityType}`);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/findings/findings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ export class FindingsService {
organizationId: string,
findingId: string,
userId: string,
memberId: string,
memberId: string | null,
) {
const finding = await this.findById(organizationId, findingId);

Expand Down
53 changes: 53 additions & 0 deletions apps/api/src/utils/file-type-validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,59 @@ describe('validateFileContent', () => {
).toThrow(BadRequestException);
});

it('should allow prose mentioning "JavaScript:" in text content', () => {
const jsonBuffer = Buffer.from(
'{"summary":"JavaScript: zod; Python: pydantic"}',
);
expect(() =>
validateFileContent(jsonBuffer, 'application/json', 'report.json'),
).not.toThrow();
});

it('should still reject javascript: URL schemes', () => {
const malicious = Buffer.from('<a href="javascript:alert(1)">x</a>');
expect(() =>
validateFileContent(malicious, 'text/html', 'evil.html'),
).toThrow(BadRequestException);
});

it('should still reject vbscript: URL schemes', () => {
const malicious = Buffer.from('<a href="vbscript:msgbox(1)">x</a>');
expect(() =>
validateFileContent(malicious, 'text/html', 'evil.html'),
).toThrow(BadRequestException);
});

it('should reject javascript: URLs with whitespace after the colon', () => {
const malicious = Buffer.from('<a href="javascript: alert(1)">x</a>');
expect(() =>
validateFileContent(malicious, 'text/html', 'evil.html'),
).toThrow(BadRequestException);
});

it('should reject javascript: in single-quoted attributes', () => {
const malicious = Buffer.from("<a href='javascript:alert(1)'>x</a>");
expect(() =>
validateFileContent(malicious, 'text/html', 'evil.html'),
).toThrow(BadRequestException);
});

it('should reject javascript: in svg xlink:href', () => {
const malicious = Buffer.from(
'<svg><a xlink:href="javascript:alert(1)">x</a></svg>',
);
expect(() =>
validateFileContent(malicious, 'image/svg+xml', 'evil.svg'),
).toThrow(BadRequestException);
});

it('should allow prose mentioning "javascript:" outside attribute context', () => {
const docs = Buffer.from('See the javascript: URL scheme documentation.');
expect(() =>
validateFileContent(docs, 'text/plain', 'notes.txt'),
).not.toThrow();
});

it('should reject a RIFF file with script content disguised as WebP', () => {
const malicious = Buffer.alloc(64);
malicious.write('RIFF', 0);
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/utils/file-type-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ const DANGEROUS_CONTENT_PATTERNS = [
/<iframe[\s>]/i,
/<object[\s>]/i,
/<embed[\s>]/i,
/javascript:/i,
/vbscript:/i,
/(?:href|src|action|formaction|xlink:href|content|poster|background|data)\s*=\s*["']?\s*javascript:/i,
/(?:href|src|action|formaction|xlink:href|content|poster|background|data)\s*=\s*["']?\s*vbscript:/i,
/\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,
];

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
'use client';

import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
TableCell,
TableRow,
Text,
} from '@trycompai/design-system';
import { Edit, OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons';

export interface AdminFinding {
id: string;
type: string;
status: string;
severity: string;
content: string;
area: string | null;
createdAt: string;
createdBy?: { user?: { name: string; email: string } } | null;
createdByAdmin?: { name: string; email: string } | null;
task?: { id: string; title: string } | null;
evidenceSubmission?: { id: string; formType: string } | null;
evidenceFormType?: string | null;
policy?: { id: string; name: string } | null;
vendor?: { id: string; name: string } | null;
risk?: { id: string; title: string } | null;
member?: { id: string; user: { name: string; email: string } } | null;
device?: { id: string; name: string; hostname: string } | null;
}

const STATUS_OPTIONS = ['open', 'ready_for_review', 'needs_revision', 'closed'];

const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
open: 'destructive',
ready_for_review: 'outline',
needs_revision: 'secondary',
closed: 'default',
};

const SEVERITY_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
low: 'outline',
medium: 'secondary',
high: 'secondary',
critical: 'destructive',
};

function formatStatus(status: string) {
return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}

function getCreatorName(finding: AdminFinding): string {
return (
finding.createdBy?.user?.name ||
finding.createdBy?.user?.email ||
finding.createdByAdmin?.name ||
finding.createdByAdmin?.email ||
'Unknown'
);
}

export function getTargetLabel(f: AdminFinding): string {
if (f.task) return `Task: ${f.task.title}`;
if (f.policy) return `Policy: ${f.policy.name}`;
if (f.vendor) return `Vendor: ${f.vendor.name}`;
if (f.risk) return `Risk: ${f.risk.title}`;
if (f.member) return `Person: ${f.member.user.name || f.member.user.email}`;
if (f.device) return `Device: ${f.device.name || f.device.hostname}`;
if (f.evidenceSubmission) return `Evidence: ${f.evidenceSubmission.formType}`;
if (f.evidenceFormType) return `Form: ${f.evidenceFormType}`;
if (f.area) return `Area: ${f.area}`;
return '—';
}

interface AdminFindingRowProps {
finding: AdminFinding;
statusUpdating: boolean;
onStatusChange: (findingId: string, newStatus: string) => void;
onEdit: (finding: AdminFinding) => void;
onDelete: (finding: AdminFinding) => void;
}

export function AdminFindingRow({
finding,
statusUpdating,
onStatusChange,
onEdit,
onDelete,
}: AdminFindingRowProps) {
return (
<TableRow>
<TableCell>
<div className="max-w-[400px] truncate">
<Text size="sm">{finding.content}</Text>
</div>
</TableCell>
<TableCell>
<Text size="sm" variant="muted">
{getTargetLabel(finding)}
</Text>
</TableCell>
<TableCell>
<Badge variant={SEVERITY_VARIANT[finding.severity] ?? 'secondary'}>
{finding.severity}
</Badge>
</TableCell>
<TableCell>
<Text size="sm" variant="muted">
{getCreatorName(finding)}
</Text>
</TableCell>
<TableCell>
<Select
value={finding.status}
onValueChange={(val) => {
if (val) onStatusChange(finding.id, val);
}}
disabled={statusUpdating}
>
<SelectTrigger size="sm">
<Badge variant={STATUS_VARIANT[finding.status] ?? 'default'}>
{formatStatus(finding.status)}
</Badge>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s} value={s}>
{formatStatus(s)}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<div className="flex justify-center">
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Finding actions"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<OverflowMenuVertical />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(finding)}>
<Edit size={16} className="mr-2" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDelete(finding)}
>
<TrashCan size={16} className="mr-2" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
);
}
Loading
Loading