diff --git a/CHANGELOG.md b/CHANGELOG.md index 3169082671..b84cf251b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ ### capa Explorer Web - webui: fix 404 for "View rule in capa-rules" by using encodeURIComponent for rule name in URL @devs6186 #2482 +- webui: show error when JSON does not follow expected result document schema; suggest reanalyzing for VT URLs @devs6186 #2363 + ### capa Explorer IDA Pro plugin ### Development diff --git a/web/explorer/src/composables/useRdocLoader.js b/web/explorer/src/composables/useRdocLoader.js index 8253183071..a02480958b 100644 --- a/web/explorer/src/composables/useRdocLoader.js +++ b/web/explorer/src/composables/useRdocLoader.js @@ -17,6 +17,9 @@ import { useToast } from "primevue/usetoast"; import { isGzipped, decompressGzip, readFileAsText } from "@/utils/fileUtils"; +const VT_REANALYZE_SUGGESTION = + "If this is a VirusTotal or similar link, the file may need to be reanalyzed. Try again later."; + export function useRdocLoader() { const toast = useToast(); const MIN_SUPPORTED_VERSION = "7.0.0"; @@ -32,6 +35,58 @@ export function useRdocLoader() { toast.add({ severity, summary, detail, life: 3000, group: "bc" }); // bc: bottom-center }; + /** + * Validates that the parsed object has the expected result document schema. + * @param {Object} rdoc - The parsed JSON data. + * @returns {{ valid: boolean, message?: string }} Validation result with an optional error message. + */ + const validateRdocSchema = (rdoc) => { + const isInvalidObject = (v) => !v || typeof v !== "object" || Array.isArray(v); + + if (isInvalidObject(rdoc)) { + return { valid: false, message: "Invalid JSON: expected an object." }; + } + if (isInvalidObject(rdoc.meta)) { + return { valid: false, message: "Invalid result document: missing or invalid 'meta' field." }; + } + if (rdoc.meta.version === undefined) { + return { valid: false, message: "Invalid result document: missing 'meta.version'." }; + } + if (isInvalidObject(rdoc.meta.analysis)) { + return { valid: false, message: "Invalid result document: missing or invalid 'meta.analysis'." }; + } + if (isInvalidObject(rdoc.meta.analysis.layout)) { + return { valid: false, message: "Invalid result document: missing or invalid 'meta.analysis.layout'." }; + } + if (isInvalidObject(rdoc.meta.analysis.feature_counts)) { + return { + valid: false, + message: "Invalid result document: missing or invalid 'meta.analysis.feature_counts'." + }; + } + const fc = rdoc.meta.analysis.feature_counts; + // Allow file-scoped-only documents (no functions/processes arrays). + // If present, functions and processes must be arrays. + if (fc.functions !== undefined && !Array.isArray(fc.functions)) { + return { + valid: false, + message: + "Invalid result document: 'meta.analysis.feature_counts.functions' must be an array when present." + }; + } + if (fc.processes !== undefined && !Array.isArray(fc.processes)) { + return { + valid: false, + message: + "Invalid result document: 'meta.analysis.feature_counts.processes' must be an array when present." + }; + } + if (isInvalidObject(rdoc.rules)) { + return { valid: false, message: "Invalid result document: missing or invalid 'rules' field." }; + } + return { valid: true }; + }; + /** * Checks if the version of the loaded data is supported. * @param {Object} rdoc - The loaded JSON data containing version information. @@ -81,27 +136,41 @@ export function useRdocLoader() { * @returns {Promise} A promise that resolves to the processed RDOC data, or null if processing fails. */ const loadRdoc = async (source) => { + const isUrl = typeof source === "string"; + try { let data; - if (typeof source === "string") { - // Load from URL + if (isUrl) { const blob = await fetchFromUrl(source); data = await processBlob(blob); } else if (source instanceof File) { - // Load from local data = await processBlob(source); } else { throw new Error("Invalid source type"); } + const validation = validateRdocSchema(data); + if (!validation.valid) { + let detail = validation.message; + if (isUrl) { + detail += VT_REANALYZE_SUGGESTION; + } + showToast("error", "Invalid result document", detail); + return null; + } + if (checkVersion(data)) { showToast("success", "Success", "JSON data loaded successfully"); return data; } } catch (error) { console.error("Error loading JSON:", error); - showToast("error", "Failed to process the file", error.message); + let detail = error.message; + if (isUrl && (error instanceof SyntaxError || error.message.includes("JSON"))) { + detail += VT_REANALYZE_SUGGESTION; + } + showToast("error", "Failed to process the file", detail); } return null; }; diff --git a/web/explorer/src/utils/rdocParser.js b/web/explorer/src/utils/rdocParser.js index 4a43634488..ad313becfb 100644 --- a/web/explorer/src/utils/rdocParser.js +++ b/web/explorer/src/utils/rdocParser.js @@ -322,8 +322,8 @@ export function parseFunctionCapabilities(doc) { }); } - // Iterate through all functions in the document - for (const f of doc.meta.analysis.feature_counts.functions) { + // Iterate through all functions in the document (empty for file-scoped-only) + for (const f of doc.meta.analysis.feature_counts.functions ?? []) { const addr = formatAddress(f.address); const matches = matchesByFunction.get(addr); // Skip functions with no matches (unlikely)