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
114 changes: 114 additions & 0 deletions src/app/api/projects/[projectId]/recon/passive-auth-surface/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {
createStoredPassiveAuthSurface,
PassiveAuthSurfaceArtifactError,
PassiveAuthSurfaceAuthorizationError,
type StoredPassiveArtifactRef,
} from "../../../../../../server/recon";
import {
assertSameOriginMutatingRequest,
badRequest,
forbidden,
handleApiError,
notFound,
ok,
readJson,
} from "../../../../_shared/http";

export const dynamic = "force-dynamic";

type Context = { params: Promise<{ projectId: string }> };

export async function POST(request: Request, context: Context) {
try {
assertSameOriginMutatingRequest(request);
const { projectId } = await context.params;
const body = parseRequest(await readJson(request));
const result = await createStoredPassiveAuthSurface({ projectId, ...body });
return ok({
authorizationId: result.authorizationId,
normalized: result.normalized,
summary: result.summary,
artifact: result.artifact,
sourceArtifacts: result.sourceArtifacts,
});
} catch (error) {
if (error instanceof PassiveAuthSurfaceAuthorizationError) {
return forbidden(error.message);
}
if (error instanceof PassiveAuthSurfaceArtifactError) {
return notFound(error.message);
}
if (
error instanceof Error &&
/required|must be|allows?|unique/i.test(error.message)
) {
return badRequest(error.message);
}
return handleApiError(error, { request });
}
}

function parseRequest(value: unknown): {
targetId: string;
threadId?: string;
taskId?: string;
artifacts: StoredPassiveArtifactRef[];
} {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Request body must be an object.");
}
const body = value as Record<string, unknown>;
const allowedKeys = new Set(["targetId", "threadId", "taskId", "artifacts"]);
if (Object.keys(body).some((key) => !allowedKeys.has(key))) {
throw new Error(
"Only targetId, threadId, taskId, and stored artifact references are allowed.",
);
}
const targetId = requiredString(body.targetId, "targetId");
const threadId = optionalString(body.threadId, "threadId");
const taskId = optionalString(body.taskId, "taskId");
if (!Array.isArray(body.artifacts)) {
throw new Error(
"artifacts must be an array of stored artifact references.",
);
}
const artifacts = body.artifacts.map((value, index) =>
parseArtifactRef(value, index),
);
return {
targetId,
...(threadId ? { threadId } : {}),
...(taskId ? { taskId } : {}),
artifacts,
};
}

function parseArtifactRef(
value: unknown,
index: number,
): StoredPassiveArtifactRef {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`artifacts[${index}] must be a stored artifact reference.`);
}
const reference = value as Record<string, unknown>;
if (Object.keys(reference).some((key) => key !== "artifactId")) {
throw new Error(`artifacts[${index}] only allows artifactId.`);
}
const artifactId = requiredString(
reference.artifactId,
`artifacts[${index}].artifactId`,
);
return { artifactId };
}

function requiredString(value: unknown, name: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${name} is required.`);
}
return value.trim();
}

function optionalString(value: unknown, name: string): string | undefined {
if (value === undefined) return undefined;
return requiredString(value, name);
}
41 changes: 39 additions & 2 deletions src/server/evidence/artifact-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
type EvidenceSource,
ingestEvidenceBestEffort,
} from "./index";
import { redactEvidenceSecrets } from "./ingestion";
import { EVIDENCE_SOURCES, redactEvidenceSecrets } from "./ingestion";

const DEFAULT_MAX_INLINE_BYTES = Number.parseInt(
process.env.ARTIFACT_INLINE_MAX_BYTES ?? "1500000",
Expand Down Expand Up @@ -93,6 +93,9 @@ export type ReadArtifactTextResult = {
contentType: string | null;
sizeBytes: number | null;
sha256: string | null;
source: EvidenceSource | null;
targetIds: string[];
targetScope: "targets" | "project" | null;
text: string;
truncated: boolean;
};
Expand Down Expand Up @@ -296,8 +299,9 @@ export function createArtifactService(config: ArtifactServiceConfig = {}) {
size_bytes: number | null;
sha256: string | null;
inline_content: string | null;
metadata: unknown;
}>(
`SELECT id, project_id, thread_id, name, content_type, storage_bucket, storage_key, size_bytes, sha256, inline_content
`SELECT id, project_id, thread_id, name, content_type, storage_bucket, storage_key, size_bytes, sha256, inline_content, metadata
FROM artifacts
WHERE id = $1 AND project_id = $2`,
[input.artifactId, input.projectId],
Expand All @@ -310,6 +314,7 @@ export function createArtifactService(config: ArtifactServiceConfig = {}) {
}

const maxBytes = normalizeReadLimit(input.maxBytes);
const provenance = readArtifactProvenance(row.metadata);
if (row.inline_content !== null) {
const redacted = redactEvidenceSecrets(row.inline_content);
const bytes = new TextEncoder().encode(redacted);
Expand All @@ -323,6 +328,7 @@ export function createArtifactService(config: ArtifactServiceConfig = {}) {
contentType: row.content_type,
sizeBytes: row.size_bytes,
sha256: row.sha256,
...provenance,
text,
truncated,
};
Expand Down Expand Up @@ -358,6 +364,7 @@ export function createArtifactService(config: ArtifactServiceConfig = {}) {
contentType: row.content_type,
sizeBytes: row.size_bytes,
sha256: row.sha256,
...provenance,
text,
truncated,
};
Expand Down Expand Up @@ -546,6 +553,36 @@ function normalizeReadLimit(value: number | undefined) {
return Math.max(1, Math.min(Math.floor(value), 5_000_000));
}

function readArtifactProvenance(value: unknown): Pick<
ReadArtifactTextResult,
"source" | "targetIds" | "targetScope"
> {
let metadata: unknown = value;
if (typeof metadata === "string") {
try {
metadata = JSON.parse(metadata);
} catch {
metadata = null;
}
}
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
return { source: null, targetIds: [], targetScope: null };
}
const record = metadata as Record<string, unknown>;
const source = EVIDENCE_SOURCES.includes(record.source as EvidenceSource)
? (record.source as EvidenceSource)
: null;
const targetIds = Array.isArray(record.targetIds)
? [...new Set(record.targetIds.filter((id): id is string => typeof id === "string" && !!id))]
: typeof record.targetId === "string" && record.targetId
? [record.targetId]
: [];
const targetScope = record.targetScope === "targets" || record.targetScope === "project"
? record.targetScope
: null;
return { source, targetIds, targetScope };
}

function isLikelyTextArtifact(name: string, contentType: string | null) {
const normalizedContentType = contentType?.toLowerCase().split(";")[0]?.trim();
if (
Expand Down
2 changes: 2 additions & 0 deletions src/server/evidence/ingestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const EVIDENCE_SOURCES = [
"patch-verification",
"patch-remediation",
"stage-handoff",
"passive-recon",
"reference",
] as const;

export type EvidenceSource = (typeof EVIDENCE_SOURCES)[number];
Expand Down
Loading