From 015dbfb210f1e3f40f9c6d3eed9e4dc59332e922 Mon Sep 17 00:00:00 2001 From: Martin Najemi Date: Sun, 31 May 2026 21:42:29 +0100 Subject: [PATCH] fix: Aliased named imports losing taint propagation Risk: low --- CHANGELOG.md | 5 +++ VERSION | 2 +- internal/analyzer/analyzer.go | 60 ++++++++++++++++++++++++----------- internal/tsparse/tsparse.go | 46 +++++++++++++++++++-------- 4 files changed, 80 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cefc3c..946dada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.21.1] - 2026-05-31 + +### Fixed +- Aliased named imports (`import { X as Y }`) now propagate taint correctly. The import parser only captured the local binding name (`Y`) and discarded the original imported name (`X`), so taint matching — which keys on the name the source module exports — never matched, and propagation stopped at the renamed hop. Imports now carry both names: the source-side name is used to match exported/affected symbols, and the local name is used to scan the importing file's body for usage. This fixes cases like `import { Root as InnerRoot }` where a change to `Root` failed to reach the importing file's exports (and therefore its entrypoint and dependent targets). + ## [0.21.0] - 2026-05-02 ### Added diff --git a/VERSION b/VERSION index 1db0ede..b9f8e55 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.21.0 \ No newline at end of file +0.21.1 \ No newline at end of file diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index bdd5ff0..9a9c9b1 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -398,12 +398,13 @@ func AnalyzeLibraryPackage(projectFolder string, entrypoints []Entrypoint, merge continue } var localNames, origNames []string - for _, name := range imp.Names { + for i, name := range imp.Names { + local := importLocalName(imp, i) if strings.HasPrefix(name, "*:") { - localNames = append(localNames, name) + localNames = append(localNames, local) origNames = append(origNames, "*") } else { - localNames = append(localNames, name) + localNames = append(localNames, local) origNames = append(origNames, name) } } @@ -525,7 +526,7 @@ func AnalyzeLibraryPackage(projectFolder string, entrypoints []Entrypoint, merge } if isCSSModule(imp.Source) && len(imp.Names) > 0 { // CSS module with assigned import: only taint symbols that use the binding - usageTainted := findTaintedSymbolsByUsage(analysis, imp.Names) + usageTainted := findTaintedSymbolsByUsage(analysis, importLocalNames(imp)) for _, s := range usageTainted { tainted[stem][s] = true } @@ -569,7 +570,7 @@ func AnalyzeLibraryPackage(projectFolder string, entrypoints []Entrypoint, merge tainted[stem] = make(map[string]bool) } if len(imp.Names) > 0 { - usageTainted := findTaintedSymbolsByUsage(analysis, imp.Names) + usageTainted := findTaintedSymbolsByUsage(analysis, importLocalNames(imp)) for _, s := range usageTainted { tainted[stem][s] = true } @@ -617,12 +618,12 @@ func AnalyzeLibraryPackage(projectFolder string, entrypoints []Entrypoint, merge continue } var taintedLocalNames []string - for _, name := range imp.Names { + for i, name := range imp.Names { if strings.HasPrefix(name, "*:") { // Namespace import — any upstream taint means the namespace is tainted - taintedLocalNames = append(taintedLocalNames, name) + taintedLocalNames = append(taintedLocalNames, importLocalName(imp, i)) } else if affectedNames[name] { - taintedLocalNames = append(taintedLocalNames, name) + taintedLocalNames = append(taintedLocalNames, importLocalName(imp, i)) } } if len(taintedLocalNames) == 0 { @@ -679,14 +680,14 @@ func AnalyzeLibraryPackage(projectFolder string, entrypoints []Entrypoint, merge } } else { // All imported names are tainted — find symbols that use them - usageTainted := findTaintedSymbolsByUsage(analysis, imp.Names) + usageTainted := findTaintedSymbolsByUsage(analysis, importLocalNames(imp)) for _, s := range usageTainted { tainted[stem][s] = true } // Check if any imported names are directly re-exported for _, exp := range analysis.Exports { if exp.Source == "" { - for _, name := range imp.Names { + for _, name := range importLocalNames(imp) { cleanName := name if strings.HasPrefix(cleanName, "*:") { cleanName = strings.TrimPrefix(cleanName, "*:") @@ -1031,6 +1032,26 @@ func AnalyzeLibraryPackage(projectFolder string, entrypoints []Entrypoint, merge return result, nil } +// importLocalName returns the local binding name for the i-th imported name, +// falling back to the source-side name when LocalNames is absent (e.g. older +// Import entries). For `import { X as Y }`, imp.Names[i] is "X" and the local +// name is "Y" — usage scans must look for the local name in the file body. +func importLocalName(imp tsparse.Import, i int) string { + if i < len(imp.LocalNames) { + return imp.LocalNames[i] + } + return imp.Names[i] +} + +// importLocalNames returns the local binding names for an import, used when +// scanning a file body for usage of the imported symbols. +func importLocalNames(imp tsparse.Import) []string { + if len(imp.LocalNames) == len(imp.Names) { + return imp.LocalNames + } + return imp.Names +} + func findTaintedSymbolsByUsage(analysis *tsparse.FileAnalysis, taintedNames []string) []string { if analysis.SourceFile == nil || len(taintedNames) == 0 { return nil @@ -1268,12 +1289,13 @@ func FindAffectedFiles(globPattern string, filterPattern string, upstreamTaint m continue } var localNames, origNames []string - for _, name := range imp.Names { + for i, name := range imp.Names { + local := importLocalName(imp, i) if strings.HasPrefix(name, "*:") { - localNames = append(localNames, name) + localNames = append(localNames, local) origNames = append(origNames, "*") } else { - localNames = append(localNames, name) + localNames = append(localNames, local) origNames = append(origNames, name) } } @@ -1411,11 +1433,11 @@ func FindAffectedFiles(globPattern string, filterPattern string, upstreamTaint m continue } var taintedLocalNames []string - for _, name := range imp.Names { + for i, name := range imp.Names { if strings.HasPrefix(name, "*:") { - taintedLocalNames = append(taintedLocalNames, name) + taintedLocalNames = append(taintedLocalNames, importLocalName(imp, i)) } else if affectedNames[name] { - taintedLocalNames = append(taintedLocalNames, name) + taintedLocalNames = append(taintedLocalNames, importLocalName(imp, i)) } } if len(taintedLocalNames) > 0 { @@ -1454,7 +1476,7 @@ func FindAffectedFiles(globPattern string, filterPattern string, upstreamTaint m } log.Debugf(" %s: all symbols tainted via external dep %s (unassigned import)", stem, imp.Source) } else { - usageTainted := findTaintedSymbolsByUsage(analysis, imp.Names) + usageTainted := findTaintedSymbolsByUsage(analysis, importLocalNames(imp)) for _, s := range usageTainted { tainted[stem][s] = true } @@ -1499,7 +1521,7 @@ func FindAffectedFiles(globPattern string, filterPattern string, upstreamTaint m tainted[stem] = make(map[string]bool) } if isCSSModule(imp.Source) && len(imp.Names) > 0 { - usageTainted := findTaintedSymbolsByUsage(analysis, imp.Names) + usageTainted := findTaintedSymbolsByUsage(analysis, importLocalNames(imp)) for _, s := range usageTainted { tainted[stem][s] = true } @@ -1545,7 +1567,7 @@ func FindAffectedFiles(globPattern string, filterPattern string, upstreamTaint m tainted[stem] = make(map[string]bool) } if len(imp.Names) > 0 { - usageTainted := findTaintedSymbolsByUsage(analysis, imp.Names) + usageTainted := findTaintedSymbolsByUsage(analysis, importLocalNames(imp)) for _, s := range usageTainted { tainted[stem][s] = true } diff --git a/internal/tsparse/tsparse.go b/internal/tsparse/tsparse.go index ad57c54..0a286e3 100644 --- a/internal/tsparse/tsparse.go +++ b/internal/tsparse/tsparse.go @@ -11,8 +11,13 @@ import ( ) type Import struct { - Names []string // imported names, or ["*:alias"] for namespace import - Source string // module specifier (e.g., "./Button/Button.js") + Names []string // imported (source-side) names, or ["*:alias"] for namespace import + // LocalNames holds the local binding name for each entry in Names (parallel slice). + // For a plain `import { X }` LocalNames[i] == Names[i]; for an aliased + // `import { X as Y }` Names[i] is "X" (what the source exports) and + // LocalNames[i] is "Y" (what this file references in its body). + LocalNames []string + Source string // module specifier (e.g., "./Button/Button.js") } type Export struct { @@ -128,22 +133,33 @@ func extractImports(stmt *ast.Node, analysis *FileAnalysis) { imp := stmt.AsImportDeclaration() source := strings.Trim(imp.ModuleSpecifier.Text(), "\"'`") - var names []string + var names, localNames []string if imp.ImportClause != nil { clause := imp.ImportClause.AsImportClause() if clause.Name() != nil { - names = append(names, clause.Name().Text()) + n := clause.Name().Text() + names = append(names, n) + localNames = append(localNames, n) } if clause.NamedBindings != nil { if ast.IsNamespaceImport(clause.NamedBindings) { ns := clause.NamedBindings.AsNamespaceImport() names = append(names, "*:"+ns.Name().Text()) + localNames = append(localNames, "*:"+ns.Name().Text()) } else if ast.IsNamedImports(clause.NamedBindings) { ni := clause.NamedBindings.AsNamedImports() if ni.Elements != nil { for _, spec := range ni.Elements.Nodes { is := spec.AsImportSpecifier() - names = append(names, is.Name().Text()) + // is.Name() is the local binding; is.PropertyName (when present) + // is the original name exported by the source module. + local := is.Name().Text() + orig := local + if is.PropertyName != nil { + orig = is.PropertyName.Text() + } + names = append(names, orig) + localNames = append(localNames, local) } } } @@ -151,8 +167,9 @@ func extractImports(stmt *ast.Node, analysis *FileAnalysis) { } analysis.Imports = append(analysis.Imports, Import{ - Names: names, - Source: source, + Names: names, + LocalNames: localNames, + Source: source, }) } @@ -422,8 +439,9 @@ func extractDynamicImports(sf *ast.SourceFile, analysis *FileAnalysis) { } if len(names) > 0 { analysis.Imports = append(analysis.Imports, Import{ - Names: names, - Source: specifier, + Names: names, + LocalNames: names, + Source: specifier, }) } } @@ -493,8 +511,9 @@ func extractDynamicImports(sf *ast.SourceFile, analysis *FileAnalysis) { nameList = append(nameList, n) } analysis.Imports = append(analysis.Imports, Import{ - Names: nameList, - Source: specifier, + Names: nameList, + LocalNames: nameList, + Source: specifier, }) } @@ -580,8 +599,9 @@ func extractThenDynamicImports(sf *ast.SourceFile, analysis *FileAnalysis) { names := extractNamesFromThenCallback(ce.Arguments.Nodes[0]) if len(names) > 0 { analysis.Imports = append(analysis.Imports, Import{ - Names: names, - Source: specifier, + Names: names, + LocalNames: names, + Source: specifier, }) staticSources[specifier] = true }