@appthreat/atom-parsetools 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/astgen.js CHANGED
@@ -29,10 +29,11 @@ import { parseSvelteFile, parseSvelteScriptBuffer } from "./svelteAst.js";
29
29
  // Printed by `astgen --version`. Downstream frontends (e.g. chen's jssrc2cpg)
30
30
  // fold this into their parse-cache fingerprint, so it MUST be bumped whenever
31
31
  // the emitted AST/type shape changes — otherwise stale cached parses from an
32
- // older astgen are silently reused. Bumped to 4.2.0 for first-class Svelte
33
- // support: `.svelte` files now emit absolute-offset Babel JSX ASTs instead of
34
- // the offset-shifted script-only output of the legacy masking path.
35
- const ASTGEN_VERSION = "4.2.0";
32
+ // older astgen are silently reused. Bumped to 4.3.0 for Vue directive
33
+ // expression values: `v-html="x"` / `:prop="x"` / `@event="x"` attribute
34
+ // values in `.vue` templates now emit JSX expression containers (real
35
+ // expression ASTs) instead of string literals.
36
+ const ASTGEN_VERSION = "4.3.0";
36
37
 
37
38
  const HELP_TEXT = `Options:
38
39
  -i, --src Source directory [default: "."]
@@ -669,6 +670,119 @@ declare module "svelte/store" {
669
670
 
670
671
  const maskNonNewlineChars = (value) => value.replace(/[^\r\n]/g, " ");
671
672
 
673
+ // Vue directive attributes - `v-html="expr"`, `:prop="expr"`, `@event="expr"`
674
+ // - carry a JavaScript expression in a quoted string. Babel parses that value
675
+ // as a StringLiteral, which severs the data flow from the script binding into
676
+ // the template: `v-html="rawContent"` has no reference to the `rawContent`
677
+ // binding, so no source-to-sink path can ever be found for the most
678
+ // security-relevant Vue shape. Replacing the value's quotes with braces turns
679
+ // it into a JSX expression container - `v-html={rawContent}` - which parses to
680
+ // a real expression AST. The swap is length-preserving (`"x"` -> `{x}`), so
681
+ // every offset in the emitted AST still maps onto the original file.
682
+ //
683
+ // Only values that parse as an expression are converted, and `v-for`/`v-slot`
684
+ // directives are skipped by name before the parse is even attempted:
685
+ // `v-for="item in items"` DOES parse (`in` is a relational operator), but the
686
+ // resulting expression is meaningless for analysis and would synthesise a read
687
+ // of the loop variable `item` that the script never declares - a spurious
688
+ // reference. Those directives keep their string value.
689
+ //
690
+ // Scoped between the root `<template>` block's opening tag and the last
691
+ // `</template>` that lies OUTSIDE any `<script>` block: a script string could
692
+ // legitimately contain the literal text `</template>`, and rewriting inside
693
+ // `<script>` would change script semantics while still parsing - a silent
694
+ // corruption. That still narrows, not eliminates, the risk - a `</template>`
695
+ // inside a template-side attribute value could in principle swallow markup up
696
+ // to a later close - which is why the whole-file candidates remain as
697
+ // fallbacks for any file the converted candidate fails to parse.
698
+ const VUE_DIRECTIVE_ATTR_REGEX =
699
+ /(\sv-[\w.:-]+|\s[:@.][\w.:-]*)=("(?:[^"\\\r\n]|\\.)*"|'(?:[^'\\\r\n]|\\.)*')/g;
700
+ const VUE_TEMPLATE_OPEN_REGEX = /<template\b[^>]*>/i;
701
+ const VUE_TEMPLATE_CLOSE = "</template>";
702
+
703
+ // `v-for` and `v-slot` (incl. `v-slot:name`) values are not plain expressions.
704
+ const VUE_NON_EXPRESSION_DIRECTIVES = /^v-(for|slot)\b/i;
705
+
706
+ const DIRECTIVE_VALUE_PARSE_OPTIONS = {
707
+ sourceType: "unambiguous",
708
+ allowImportExportEverywhere: true,
709
+ allowAwaitOutsideFunction: true,
710
+ allowReturnOutsideFunction: true,
711
+ allowSuperOutsideMethod: true,
712
+ allowUndeclaredExports: true,
713
+ errorRecovery: false,
714
+ plugins: babelSyntaxPlugins
715
+ };
716
+
717
+ // Ranges of the file covered by `<script ...>...</script>` blocks, so the
718
+ // template-region search can ignore `</template>` text that only appears
719
+ // inside a script string.
720
+ const vueScriptRanges = (code) => {
721
+ const ranges = [];
722
+ let scriptMatch;
723
+ vueScriptTagRegex.lastIndex = 0;
724
+ while ((scriptMatch = vueScriptTagRegex.exec(code)) !== null) {
725
+ ranges.push([scriptMatch.index, scriptMatch.index + scriptMatch[0].length]);
726
+ }
727
+ return ranges;
728
+ };
729
+
730
+ const vueDirectiveValueToExpression = (code) => {
731
+ const templateOpen = code.match(VUE_TEMPLATE_OPEN_REGEX);
732
+ if (!templateOpen) {
733
+ return { code, changed: false };
734
+ }
735
+ const templateStart = templateOpen.index + templateOpen[0].length;
736
+ const scriptRanges = vueScriptRanges(code);
737
+ const inScript = (index) =>
738
+ scriptRanges.some(([start, end]) => index >= start && index < end);
739
+ let templateEnd = -1;
740
+ let searchFrom = templateStart;
741
+ let closeIndex;
742
+ while (
743
+ (closeIndex = code.toLowerCase().indexOf(VUE_TEMPLATE_CLOSE, searchFrom)) !==
744
+ -1
745
+ ) {
746
+ if (!inScript(closeIndex)) {
747
+ templateEnd = closeIndex;
748
+ }
749
+ searchFrom = closeIndex + 1;
750
+ }
751
+ if (templateEnd < templateStart) {
752
+ return { code, changed: false };
753
+ }
754
+
755
+ let changed = false;
756
+ const convert = (templateSlice) =>
757
+ templateSlice.replace(
758
+ VUE_DIRECTIVE_ATTR_REGEX,
759
+ (match, namePart, quoted) => {
760
+ if (VUE_NON_EXPRESSION_DIRECTIVES.test(namePart.trim())) {
761
+ return match;
762
+ }
763
+ const inner = quoted.slice(1, -1);
764
+ // An empty value, or a `{{` opening (Vue ignores interpolations in
765
+ // attribute values), is left alone.
766
+ if (!inner.trim() || inner.includes("{{")) {
767
+ return match;
768
+ }
769
+ try {
770
+ parse(`(${inner})`, DIRECTIVE_VALUE_PARSE_OPTIONS);
771
+ } catch {
772
+ return match;
773
+ }
774
+ changed = true;
775
+ return `${namePart}={${inner}}`;
776
+ }
777
+ );
778
+
779
+ const converted =
780
+ code.slice(0, templateStart) +
781
+ convert(code.slice(templateStart, templateEnd)) +
782
+ code.slice(templateEnd);
783
+ return { code: converted, changed };
784
+ };
785
+
672
786
  const cleanVueCodeForParsing = (code, { includeScripts = true } = {}) => {
673
787
  let cleanedCode = code
674
788
  .replace(vueCommentRegex, (match) => maskNonNewlineChars(match))
@@ -730,7 +844,32 @@ const buildVueParseCandidates = (code) => {
730
844
  ? `${scriptOnlyCandidate}\n${templateOnlyCandidate}`
731
845
  : templateOnlyCandidate;
732
846
 
847
+ // Directive-expression candidates first: same masking, but `v-html="x"`
848
+ // style values are expression containers, so template expressions keep
849
+ // their references. The plain candidates below remain as fallbacks for the
850
+ // (checked-per-attribute, but defensive) case of a converted file not
851
+ // parsing as a whole.
852
+ const directiveCandidates = (() => {
853
+ const { code: directiveCode, changed } = vueDirectiveValueToExpression(
854
+ code
855
+ );
856
+ if (!changed) {
857
+ return [];
858
+ }
859
+ const directiveFull = cleanVueCodeForParsing(directiveCode, {
860
+ includeScripts: true
861
+ });
862
+ const directiveTemplateOnly = cleanVueCodeForParsing(directiveCode, {
863
+ includeScripts: false
864
+ });
865
+ return [
866
+ { name: "directive-full", code: directiveFull },
867
+ { name: "directive-template-only", code: directiveTemplateOnly }
868
+ ];
869
+ })();
870
+
733
871
  const candidates = [
872
+ ...directiveCandidates,
734
873
  { name: "full", code: fullCandidate },
735
874
  { name: "combined", code: combinedCandidate },
736
875
  { name: "template-only", code: templateOnlyCandidate },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthreat/atom-parsetools",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Parsing tools that complement the @appthreat/atom project.",
5
5
  "main": "./index.js",
6
6
  "type": "module",
@@ -1,9 +1,9 @@
1
1
  <?php return array(
2
2
  'root' => array(
3
3
  'name' => '__root__',
4
- 'pretty_version' => 'v1.6.0',
5
- 'version' => '1.6.0.0',
6
- 'reference' => '6b530ed51ff42e8344f6944bf29a6f0042518cdc',
4
+ 'pretty_version' => 'v1.7.0',
5
+ 'version' => '1.7.0.0',
6
+ 'reference' => '26f7c3eeab1661fdf7ee1a5a7b40f0fd034235eb',
7
7
  'type' => 'library',
8
8
  'install_path' => __DIR__ . '/../../',
9
9
  'aliases' => array(),
@@ -11,9 +11,9 @@
11
11
  ),
12
12
  'versions' => array(
13
13
  '__root__' => array(
14
- 'pretty_version' => 'v1.6.0',
15
- 'version' => '1.6.0.0',
16
- 'reference' => '6b530ed51ff42e8344f6944bf29a6f0042518cdc',
14
+ 'pretty_version' => 'v1.7.0',
15
+ 'version' => '1.7.0.0',
16
+ 'reference' => '26f7c3eeab1661fdf7ee1a5a7b40f0fd034235eb',
17
17
  'type' => 'library',
18
18
  'install_path' => __DIR__ . '/../../',
19
19
  'aliases' => array(),