Strengthen file scan evidence
This commit is contained in:
1 parent
9cc4c3b44a
commit
7c978c6a10
3 files changed
+165
-11
No files matched your search
@@ -4,16 +4,32 @@ import { basename, extname, join } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { dialog } from "electron";
|
||||
import type { ImportFileInput } from "../../shared/commands";
|
||||
import { FILE_SCAN_STATE } from "../../shared/domain";
|
||||
import { FILE_SCAN_STATE, type FileScanState } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
interface FileScanResult {
|
||||
state: typeof FILE_SCAN_STATE.APPROVED;
|
||||
state: FileScanState;
|
||||
labels: string[];
|
||||
scannedAt: string;
|
||||
}
|
||||
|
||||
interface TextScanPattern {
|
||||
label: string;
|
||||
pattern: RegExp;
|
||||
block: boolean;
|
||||
}
|
||||
|
||||
const TEXT_SCAN_PATTERNS: TextScanPattern[] = [
|
||||
{ label: "dlp:private-key", pattern: /-----BEGIN (?:RSA |DSA |EC |OPENSSH |PRIVATE )?PRIVATE KEY-----/i, block: true },
|
||||
{ label: "dlp:aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/, block: true },
|
||||
{ label: "dlp:github-token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/, block: true },
|
||||
{ label: "dlp:provider-secret", pattern: /\bsk-[A-Za-z0-9]{32,}\b/, block: true },
|
||||
{ label: "dlp:credential", pattern: /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*["']?[A-Za-z0-9_\-./+=]{20,}/i, block: true },
|
||||
{ label: "dlp:ssn", pattern: /\b\d{3}-\d{2}-\d{4}\b/, block: true },
|
||||
{ label: "dlp:email", pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i, block: false },
|
||||
];
|
||||
|
||||
function fileKind(path: string): string {
|
||||
const ext = extname(path).toLowerCase().replace(".", "");
|
||||
if (!ext) return "file";
|
||||
@@ -91,7 +107,7 @@ export class FileService {
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "imported",
|
||||
payload: { sha256: hash, sizeBytes: stats.size, scanState: scan.state },
|
||||
payload: { sha256: hash, sizeBytes: stats.size, scanState: scan.state, scanLabels: scan.labels, objectRef: target },
|
||||
});
|
||||
this.db.exec("commit");
|
||||
} catch (error) {
|
||||
@@ -141,13 +157,26 @@ export class FileService {
|
||||
}
|
||||
|
||||
function scanFile(sourcePath: string, bytes: Buffer): FileScanResult {
|
||||
const sample = bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString("latin1");
|
||||
if (sample.includes("EICAR-STANDARD-ANTIVIRUS-TEST-FILE")) {
|
||||
throw new Error("File scan blocked a known malware signature.");
|
||||
const textSample = sampleText(bytes);
|
||||
const labels = new Set(scanLabels(sourcePath, bytes.length));
|
||||
let blocked = false;
|
||||
if (bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString("latin1").includes("EICAR-STANDARD-ANTIVIRUS-TEST-FILE")) {
|
||||
labels.add("av:eicar");
|
||||
blocked = true;
|
||||
}
|
||||
for (const scan of TEXT_SCAN_PATTERNS) {
|
||||
if (!scan.pattern.test(textSample)) continue;
|
||||
labels.add(scan.label);
|
||||
if (scan.block) blocked = true;
|
||||
}
|
||||
if (containsPaymentCard(textSample)) {
|
||||
labels.add("dlp:payment-card");
|
||||
blocked = true;
|
||||
}
|
||||
labels.add(blocked ? "classification:restricted" : labels.has("dlp:email") ? "classification:confidential" : "classification:internal");
|
||||
return {
|
||||
state: FILE_SCAN_STATE.APPROVED,
|
||||
labels: scanLabels(sourcePath, bytes.length),
|
||||
state: blocked ? FILE_SCAN_STATE.BLOCKED : FILE_SCAN_STATE.APPROVED,
|
||||
labels: [...labels].sort(),
|
||||
scannedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -158,3 +187,28 @@ function scanLabels(sourcePath: string, sizeBytes: number): string[] {
|
||||
if (sizeBytes > 100 * 1024 * 1024) labels.add("large");
|
||||
return [...labels].sort();
|
||||
}
|
||||
|
||||
function sampleText(bytes: Buffer): string {
|
||||
return bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString("utf8");
|
||||
}
|
||||
|
||||
function containsPaymentCard(text: string): boolean {
|
||||
const matches = text.match(/\b(?:\d[ -]?){13,19}\b/g) ?? [];
|
||||
return matches.some((match) => luhnValid(match.replace(/[ -]/g, "")));
|
||||
}
|
||||
|
||||
function luhnValid(value: string): boolean {
|
||||
if (!/^\d{13,19}$/.test(value)) return false;
|
||||
let sum = 0;
|
||||
let doubleDigit = false;
|
||||
for (let index = value.length - 1; index >= 0; index -= 1) {
|
||||
let digit = Number(value[index]);
|
||||
if (doubleDigit) {
|
||||
digit *= 2;
|
||||
if (digit > 9) digit -= 9;
|
||||
}
|
||||
sum += digit;
|
||||
doubleDigit = !doubleDigit;
|
||||
}
|
||||
return sum % 10 === 0;
|
||||
}
|
||||
@@ -305,7 +305,12 @@ function FileRow({
|
||||
>
|
||||
<div className="row" style={{ gap: 10, minWidth: 0 }}>
|
||||
<FileIcon kind={file.kind} />
|
||||
<span style={{ fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{file.name}</span>
|
||||
<div style={{ display: "grid", gap: 3, minWidth: 0 }}>
|
||||
<span style={{ fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{file.name}</span>
|
||||
<span className="mono" style={{ color: "var(--text-3)", fontSize: 10, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
sha:{shortChecksum(file.sha256)} · obj:{shortObjectRef(file.storagePath)}
|
||||
</span>
|
||||
</div>
|
||||
{file.pinned && <Pill tone="violet" dot>Pinned</Pill>}
|
||||
</div>
|
||||
<div className="num mono" style={{ color: "var(--text-3)", fontSize: 12 }}>{formatBytes(file.sizeBytes)}</div>
|
||||
@@ -340,7 +345,7 @@ function PinnedCard({
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
borderRadius: 8,
|
||||
background: "var(--panel)",
|
||||
padding: 14,
|
||||
display: "flex",
|
||||
@@ -363,10 +368,14 @@ function PinnedCard({
|
||||
<div style={{ fontSize: 13, fontWeight: 540, letterSpacing: "-0.005em", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{file.name}
|
||||
</div>
|
||||
<div className="mono" style={{ color: "var(--text-3)", fontSize: 10, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
sha:{shortChecksum(file.sha256)} · obj:{shortObjectRef(file.storagePath)}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<Pill tone={file.scanState === "approved" ? "green" : "red"}>{file.scanState === "approved" ? "Clean" : "Blocked"}</Pill>
|
||||
<span className="mono" style={{ color: "var(--text-3)", fontSize: 11 }}>{grantCount}</span>
|
||||
</div>
|
||||
<ScanLabelStrip file={file} />
|
||||
<div className="row" style={{ gap: 8, marginTop: "auto", minWidth: 0 }}>
|
||||
<Avatar name={ownerName} size="sm" bot={isAgentOwner(file)} />
|
||||
<span style={{ fontSize: 11, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
@@ -377,6 +386,18 @@ function PinnedCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ScanLabelStrip({ file }: { file: FileObjectRecord }) {
|
||||
const labels = parseScanLabels(file).slice(0, 2);
|
||||
if (labels.length === 0) return null;
|
||||
return (
|
||||
<div className="row" style={{ gap: 5, minWidth: 0, flexWrap: "wrap" }}>
|
||||
{labels.map((label) => (
|
||||
<Pill key={label}>{label}</Pill>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon({ kind, big = false }: { kind: string; big?: boolean }) {
|
||||
const map = FILE_COLOR_MAP[kind] ?? FILE_COLOR_MAP.doc;
|
||||
if (!map) throw new Error("File color map default missing.");
|
||||
@@ -443,6 +464,25 @@ function fileGrantCount(fileId: string, grants: FileAccessGrantRecord[]): number
|
||||
return grants.filter((grant) => grant.fileId === fileId).length;
|
||||
}
|
||||
|
||||
function parseScanLabels(file: FileObjectRecord): string[] {
|
||||
const parsed: unknown = JSON.parse(file.scanLabelsJson);
|
||||
if (!Array.isArray(parsed) || parsed.some((label) => typeof label !== "string")) {
|
||||
throw new Error("File scan labels are invalid.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function shortChecksum(value: string): string {
|
||||
if (!/^[a-f0-9]{8,}$/i.test(value)) throw new Error("File checksum is invalid.");
|
||||
return value.slice(0, 8);
|
||||
}
|
||||
|
||||
function shortObjectRef(value: string): string {
|
||||
const parts = value.split(/[\\/]/).filter(Boolean);
|
||||
if (parts.length === 0) throw new Error("File object reference is invalid.");
|
||||
return parts.slice(-2).join("/");
|
||||
}
|
||||
|
||||
function isRecent(iso: string): boolean {
|
||||
const sevenDays = 7 * 24 * 60 * 60 * 1000;
|
||||
return Date.now() - new Date(iso).getTime() <= sevenDays;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { FileService } from "../../src/main/services/file-service";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||
import { SkillService } from "../../src/main/services/skill-service";
|
||||
@@ -396,6 +397,65 @@ describe("domain services", () => {
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("imports files with checksum, scan labels, object refs, and workspace grants", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
const artifactRoot = join(tmpdir(), `agentcompany-file-artifacts-${randomUUID()}`);
|
||||
const fileService = new FileService(connection.db, artifactRoot);
|
||||
const sourceDir = join(tmpdir(), `agentcompany-file-sources-${randomUUID()}`);
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
const cleanBody = "Release checklist for dana@example.com\n";
|
||||
const secretBody = "-----BEGIN OPENSSH PRIVATE KEY-----\nabc123\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
const cleanPath = join(sourceDir, "release-notes.md");
|
||||
const secretPath = join(sourceDir, "deploy.env");
|
||||
writeFileSync(cleanPath, cleanBody);
|
||||
writeFileSync(secretPath, secretBody);
|
||||
let orgId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
});
|
||||
const cleanFileId = fileService.importPath(orgId, cleanPath);
|
||||
const blockedFileId = fileService.importPath(orgId, secretPath);
|
||||
|
||||
const state = snapshot.load();
|
||||
const cleanFile = state.files.find((file) => file.id === cleanFileId);
|
||||
const blockedFile = state.files.find((file) => file.id === blockedFileId);
|
||||
if (!cleanFile || !blockedFile) throw new Error("Imported files were not recorded.");
|
||||
expect(cleanFile.sha256).toBe(createHash("sha256").update(cleanBody).digest("hex"));
|
||||
expect(cleanFile.storagePath).toContain(cleanFileId);
|
||||
expect(cleanFile.scanState).toBe("approved");
|
||||
expect(JSON.parse(cleanFile.scanLabelsJson)).toEqual(expect.arrayContaining(["classification:confidential", "dlp:email", "kind:md"]));
|
||||
expect(blockedFile.scanState).toBe("blocked");
|
||||
expect(JSON.parse(blockedFile.scanLabelsJson)).toEqual(expect.arrayContaining(["classification:restricted", "dlp:private-key", "kind:env"]));
|
||||
expect(state.fileAccessGrants).toContainEqual(expect.objectContaining({
|
||||
fileId: cleanFileId,
|
||||
grantedToType: "org_group",
|
||||
grantedToId: `${orgId}:workspace`,
|
||||
accessLevel: "manage",
|
||||
}));
|
||||
expect(state.fileAccessGrants).toContainEqual(expect.objectContaining({
|
||||
fileId: blockedFileId,
|
||||
grantedToType: "org_group",
|
||||
grantedToId: `${orgId}:workspace`,
|
||||
accessLevel: "manage",
|
||||
}));
|
||||
const importAudit = state.auditEvents.find((event) => event.entityId === blockedFileId && event.action === "imported");
|
||||
if (!importAudit) throw new Error("File import audit event was not recorded.");
|
||||
expect(importAudit.payloadJson).toContain("\"scanState\":\"blocked\"");
|
||||
expect(importAudit.payloadJson).toContain("dlp:private-key");
|
||||
expect(importAudit.payloadJson).toContain("objectRef");
|
||||
rmSync(sourceDir, { recursive: true, force: true });
|
||||
rmSync(artifactRoot, { recursive: true, force: true });
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("creates approval and waiting presence from chat escalation", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
|
||||
Reference in new issue
Block a user