diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 4699e08e6..823c81f7d 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -278,6 +278,9 @@ const addStorage = ( context: JavaScriptArtifactGraphContext, input: FindingInput, ): void => { + const candidate = input.value.interpretation === "candidate"; + const limitation = + "The constructor name is only a candidate; an unchanged SQLite module API origin is unproven."; const source = sourceNodeFor( context, @@ -289,7 +292,7 @@ const addStorage = ( kind: "storage", identity: artifactLocalIdentity( input.file.sha256, - input.value.kind, + candidate ? `${input.value.kind}:candidate` : input.value.kind, input.value.name ?? input.value.mechanism, ), observations: [ @@ -299,14 +302,25 @@ const addStorage = ( storage_kind: input.value.kind, name: input.value.name, mechanism: input.value.mechanism, + ...(candidate ? { interpretation: "candidate" } : {}), }, - evidence: astObservationEvidence({ - sha256: input.file.sha256, - path: input.file.path, - range: input.value.location, - operation: "discover-static-storage", - coverage: input.coverage, - }), + evidence: candidate + ? staticInferenceEvidence({ + sha256: input.file.sha256, + path: input.file.path, + range: input.value.location, + operation: "classify-static-storage-candidate", + coverage: input.coverage, + confidence: "low", + limitations: [limitation], + }) + : astObservationEvidence({ + sha256: input.file.sha256, + path: input.file.path, + range: input.value.location, + operation: "discover-static-storage", + coverage: input.coverage, + }), }, ], }); @@ -317,7 +331,13 @@ const addStorage = ( range: input.value.location, coverage: input.coverage, relation: "persists_to", - properties: { mechanism: input.value.mechanism }, + properties: { + mechanism: input.value.mechanism, + ...(candidate ? { interpretation: "candidate" } : {}), + }, + ...(candidate + ? { confidence: "low" as const, limitations: [limitation] } + : {}), }); }; diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index 6c8321043..f3720815e 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -227,6 +227,20 @@ export const evaluateSemanticExpression = ( ); }; +/** Resolve an expression using the same alias and module provenance owner. */ +export const evaluateSemanticExpressionProvenance = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): JavaScriptBindingProvenance => + provenanceForExpression(node, { + state, + bindings: new Map(), + expressionDepth: 0, + primitiveBindingValues: primitiveBindingValuesFor(state), + dependence: { lowest: CALLER_INDEPENDENT }, + memo: createEvaluationMemo(state), + }); + /** Follow module provenance through destructuring, members, and aliases. */ export const evaluateSemanticProvenance = ( binding: JavaScriptSemanticBindingState, @@ -963,7 +977,16 @@ const provenanceForBindingValue = ( : "Catch binding provenance depends on the thrown value.", ); if (binding.directOrigins.length > 0) - return semanticOriginsProvenance(binding.directOrigins); + return binding.kind === "import" && + !binding.runtimeImport && + binding.directOrigins.every( + ({ resolutionMode }) => resolutionMode !== "require", + ) + ? semanticAmbiguousProvenance( + binding.directOrigins, + "A type-only import does not establish a runtime module value.", + ) + : semanticOriginsProvenance(binding.directOrigins); if (binding.initializers.length === 0) return semanticLocalProvenance(); if (binding.initializers.length > 1) return semanticAmbiguousProvenance( @@ -1151,8 +1174,10 @@ const isLocallyCreatedValue = (node: t.Node): boolean => t.isObjectExpression(node) || t.isArrayExpression(node) || t.isFunctionExpression(node) || + t.isFunctionDeclaration(node) || t.isArrowFunctionExpression(node) || t.isClassExpression(node) || + t.isClassDeclaration(node) || t.isTemplateLiteral(node) || t.isUnaryExpression(node) || t.isBinaryExpression(node) || diff --git a/src/domain/javascript/javascriptStaticAnalysis.sqliteStorage.test.ts b/src/domain/javascript/javascriptStaticAnalysis.sqliteStorage.test.ts new file mode 100644 index 000000000..0e45dd610 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysis.sqliteStorage.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from "vitest"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it.each([ + 'class Database { constructor(name) { this.name = name; } } new Database("hello");', + 'const Database = class {}; new Database("hello");', + 'function Database(name) { return {name}; } new Database("hello");', + 'class Local {} const Database = Local; new Database("hello");', +])("omits a proven local constructor: %s", (source) => { + expect(analyzeJavaScriptStaticSource(source).storage).toEqual([]); +}); + +it.each([ + 'new Database("shared");', + 'import type Database = require("better-sqlite3"); new Database("shared");', + 'import * as sqlite from "sqlite3"; const alias = sqlite; alias.Database = Other; new sqlite.Database("shared");', + 'import Database from "better-sqlite3"; expose(Database); new Database("shared");', + 'import type TypeDatabase from "better-sqlite3"; const Database = TypeDatabase; new Database("shared");', + 'import * as Database from "better-sqlite3"; new Database("shared");', + 'const Database = require("better-sqlite3").default; new Database("shared");', + 'const sqlite = require("sqlite3").default; new sqlite.Database("shared");', + 'import type Database from "better-sqlite3"; new Database("shared");', + 'import * as sqlite from "sqlite3"; new sqlite.default.default.Database("shared");', + 'function run(Database) { return new Database("shared"); }', + 'import Database from "unknown-library"; new Database("shared");', + 'import Database from "better-sqlite3"; Database = Other; new Database("shared");', + 'import * as sqlite from "sqlite3"; sqlite.Database = Other; new sqlite.Database("shared");', +])("retains uncertain SQLite names as candidates: %s", (source) => { + expect(analyzeJavaScriptStaticSource(source).storage).toMatchObject([ + { kind: "sqlite", name: "shared", interpretation: "candidate" }, + ]); +}); + +it.each([ + 'import Database from "better-sqlite3"; new Database("known.db");', + 'import Database = require("better-sqlite3"); new Database("known.db");', + 'import sqlite = require("sqlite3"); new sqlite.Database("known.db");', + 'const Database = require("better-sqlite3"); new Database("known.db");', + 'import {Database} from "sqlite3"; new Database("known.db");', + 'import * as sqlite from "sqlite3"; new sqlite.Database("known.db");', +])("retains proven SQLite API origins: %s", (source) => { + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.storage).toHaveLength(1); + expect(analysis.storage[0]).toMatchObject({ + kind: "sqlite", + name: "known.db", + }); + expect(analysis.storage[0]).not.toHaveProperty("interpretation"); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 6d6ca3e8a..758355af2 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -23,6 +23,7 @@ import { addReference, addSourceMapDirectives, finishPartialNetworkEndpoints, + finishSqliteStorageCalls, inspectCall, inspectRouteProperty, inspectRoleProperty, @@ -101,9 +102,13 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( }; yield* traverseStaticSourceSteps(file, findings, bindings); yield* finishElectronBrowserWindowsSteps(findings, index); - if (accumulator.pendingNetworkCalls.length > 0) { + if ( + accumulator.pendingNetworkCalls.length > 0 || + accumulator.pendingSqliteCalls.length > 0 + ) { yield* index.indexMemberMutationFactsSteps(); finishPartialNetworkEndpoints(findings, bindings, index); + finishSqliteStorageCalls(findings, bindings, index); } accumulator.electronCallPaths = collectElectronWrappedCallPaths( file.program, diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 763861d70..04a504d6a 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -42,7 +42,10 @@ import { isUnmodifiedGlobalReference, isUnmodifiedImportMetaUrl, } from "./javascriptSemanticAmbientWrites.js"; -import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; +import { + evaluateSemanticExpression, + evaluateSemanticExpressionProvenance, +} from "./javascriptSemanticValues.js"; import { semanticTopLevelEffectFollowsCapture } from "./javascriptSemanticMutationInitializers.js"; import { semanticFactoryLoaderMemberAt, @@ -747,6 +750,10 @@ const inspectStorageCall = ( first: string | undefined, context: FindingContext, ): void => { + if (name === "Database" || name.endsWith(".Database")) { + context.accumulator.pendingSqliteCalls.push({ node, name }); + return; + } const receiverFact = context.openReceiverFacts?.get(node.start ?? -1); const storage = receiverFact === "local" || @@ -773,6 +780,53 @@ const inspectStorageCall = ( }); }; +/** Constructor spelling selects a candidate; source provenance owns certainty. */ +export const finishSqliteStorageCalls = ( + context: FindingContext, + bindings: JavaScriptSemanticAnalysisState, + index: JavaScriptBindingIndex, +): void => { + for (const { node, name } of context.accumulator.pendingSqliteCalls) { + const callee = unwrapJavaScriptExpression(node.callee).node; + const provenance = evaluateSemanticExpressionProvenance(callee, bindings); + const changed = index.referenceMayHaveChangedAt(node, callee); + if (!changed && t.isIdentifier(callee) && provenance.status === "local") + continue; + const origin = + provenance.status === "module" ? provenance.origins[0] : undefined; + const path = origin?.importedPath; + const imported = origin?.resolutionMode !== "require"; + const known = + !changed && + origin !== undefined && + ((origin.specifier === "better-sqlite3" && + ((origin.resolutionMode === "require" && path?.length === 0) || + (imported && path?.length === 1 && path[0] === "default"))) || + (origin.specifier === "sqlite3" && + ((path?.length === 1 && path[0] === "Database") || + (imported && + path?.length === 2 && + (path[0] === "default" || path[0] === "*") && + path[1] === "Database")))); + const first = stringValue(node.arguments[0]); + addLocatedFinding(context, { + collection: context.accumulator.storage, + key: `storage\0sqlite\0${known ? "api" : "candidate"}\0${first ?? ""}`, + node, + value: { + kind: "sqlite", + name: first ?? null, + mechanism: `call:${name}`, + ...(known ? {} : { interpretation: "candidate" as const }), + module_key: null, + location: range(node), + }, + }); + } + context.accumulator.pendingSqliteCalls.length = 0; + context.accumulator.storage.sort((left, right) => left.offset - right.offset); +}; + export const addReference = ( context: FindingContext, input: ReferenceInput, diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 72a3d4079..952b2c7d0 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -675,7 +675,6 @@ export const storageKind = ( if (name.includes("sessionStorage.")) return "session-storage"; if (name.endsWith("indexedDB.open")) return "indexed-db"; if (name.endsWith("caches.open")) return "cache-storage"; - if (name === "Database" || name.endsWith(".Database")) return "sqlite"; return undefined; }; diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 0e875e46a..d03580554 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -50,6 +50,13 @@ export interface JavaScriptAnalysisAccumulator { | t.NewExpression; readonly name: string; }[]; + readonly pendingSqliteCalls: { + readonly node: + | t.CallExpression + | t.OptionalCallExpression + | t.NewExpression; + readonly name: string; + }[]; readonly storage: LocatedJavaScriptFinding[]; readonly roles: LocatedJavaScriptFinding[]; readonly sourceMaps: JavaScriptStaticAnalysis["source_map_urls"][number][]; @@ -117,6 +124,7 @@ export const createJavaScriptAnalysisAccumulator = ( endpoints: [], pendingNetworkCalls: [], storage: [], + pendingSqliteCalls: [], roles: [], sourceMaps: [], registrations: [], diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts index 7838d2ae3..b3a5fc8c0 100644 --- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts @@ -55,7 +55,7 @@ export interface JavaScriptStaticEndpoint { readonly location: JavaScriptSourceRange; } -/** A storage surface observed without executing application code. */ +/** A storage API or explicitly uncertain candidate, without executing source. */ export interface JavaScriptStaticStorage { readonly kind: | "local-storage" @@ -63,6 +63,7 @@ export interface JavaScriptStaticStorage { | "indexed-db" | "cache-storage" | "sqlite"; + readonly interpretation?: "candidate"; readonly name: string | null; readonly mechanism: string; readonly module_key: string | null; diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 17be5e074..e3da83f93 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -344,6 +344,7 @@ export const javaScriptWorkerStaticAnalysisSchema = z "cache-storage", "sqlite", ]), + interpretation: z.literal("candidate").exactOptional(), name: z.string().nullable(), mechanism: z.string(), }), diff --git a/tests/acceptance/applications/javascriptEndpointCandidateCli.test.ts b/tests/acceptance/applications/javascriptEndpointCandidateCli.test.ts index 38027906e..77823fc54 100644 --- a/tests/acceptance/applications/javascriptEndpointCandidateCli.test.ts +++ b/tests/acceptance/applications/javascriptEndpointCandidateCli.test.ts @@ -80,3 +80,61 @@ it("reports a candidate endpoint through the worker boundary in the public CLI", ); expect(edge?.properties).toMatchObject({ interpretation: "candidate" }); }, 20_000); + +it("preserves SQLite constructor certainty through the public worker and graph", async () => { + const root = await createTestTempDirectory("rea-storage-candidate-cli-"); + await writeFile( + join(root, "main.mjs"), + ` + import Known from "better-sqlite3"; + class Database { constructor(name) { this.name = name; } } + new Database("local"); + function uncertain(Database) { return new Database("shared"); } + { const Database = Known; new Database("shared"); } + `, + ); + const { stdout } = await execute( + process.execPath, + ["scripts/rea.mjs", "analyze-javascript-application", root, "--json"], + { cwd: process.cwd(), maxBuffer: 16 * 1024 * 1024 }, + ); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(JSON.parse(stdout)).normalized_result, + ); + const storage = result.graph.nodes.filter((node) => node.kind === "storage"); + expect(storage).toHaveLength(2); + const candidate = storage.find( + (node) => node.observations[0]?.properties.interpretation === "candidate", + ); + const observed = storage.find( + (node) => node.observations[0]?.properties.interpretation === undefined, + ); + expect(candidate?.node_id).not.toBe(observed?.node_id); + expect(candidate?.observations[0]).toMatchObject({ + properties: { storage_kind: "sqlite", name: "shared" }, + evidence: { + state: "inferred", + confidence: "low", + limitations: expect.arrayContaining([ + expect.stringContaining("only a candidate"), + ]), + }, + }); + expect(observed?.observations[0]).toMatchObject({ + properties: { storage_kind: "sqlite", name: "shared" }, + evidence: { state: "observed", confidence: "exact" }, + }); + expect( + result.graph.edges.find( + (edge) => edge.target_node_id === candidate?.node_id, + ), + ).toMatchObject({ + properties: { interpretation: "candidate" }, + evidence: { + confidence: "low", + limitations: expect.arrayContaining([ + expect.stringContaining("only a candidate"), + ]), + }, + }); +}, 20_000);