biome-plugin-suitescript 0.1.1 → 0.3.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.
Files changed (78) hide show
  1. package/README.md +157 -27
  2. package/biome-manifest.json +261 -7
  3. package/configs/all.json +29 -0
  4. package/configs/minimal.json +14 -0
  5. package/configs/recommended.json +20 -0
  6. package/dist/cli/analyze.js +33 -3
  7. package/dist/lib/define.js +35 -5
  8. package/dist/lib/headers.js +12 -0
  9. package/dist/lib/module-calls.js +65 -0
  10. package/dist/lib/module-script-types.js +76 -0
  11. package/dist/lib/options.js +35 -0
  12. package/dist/lib/required-options.js +14 -0
  13. package/dist/lib/script-side.js +83 -0
  14. package/dist/lib/script-types.js +37 -14
  15. package/dist/lib/suiteql.js +16 -0
  16. package/dist/lib/suitescript.js +23 -0
  17. package/dist/lib/typings-data.js +4327 -0
  18. package/dist/lib/typings.js +2 -0
  19. package/dist/rules/logRequireDetails.js +4 -0
  20. package/dist/rules/logRequireTitle.js +1 -0
  21. package/dist/rules/noAuthorizationHeaderInInternalRequest.js +24 -0
  22. package/dist/rules/noBeforeSubmitRecordReload.js +19 -0
  23. package/dist/rules/noBlockedHttpsHeader.js +32 -0
  24. package/dist/rules/noBlockedResponseHeader.js +35 -0
  25. package/dist/rules/noBrowserGlobals.js +25 -0
  26. package/dist/rules/noClientLog.js +14 -0
  27. package/dist/rules/noConflictingClientScriptProps.js +36 -0
  28. package/dist/rules/noDynamicRequire.js +23 -0
  29. package/dist/rules/noDynamicSuiteQL.js +29 -0
  30. package/dist/rules/noEmptyEntryPoints.js +19 -0
  31. package/dist/rules/noExtraDeps.js +16 -0
  32. package/dist/rules/noExtraParams.js +17 -0
  33. package/dist/rules/noGlobalShadowing.js +38 -0
  34. package/dist/rules/noHardcodedAuthPasswords.js +27 -0
  35. package/dist/rules/noHardcodedSecrets.js +43 -0
  36. package/dist/rules/noHeaderUnderscores.js +23 -0
  37. package/dist/rules/noInnerHtml.js +41 -0
  38. package/dist/rules/noInsecureCookie.js +27 -0
  39. package/dist/rules/noInsecureHttpsUrl.js +20 -0
  40. package/dist/rules/noInvalidDefine.js +47 -0
  41. package/dist/rules/noInvalidEnumMember.js +24 -0
  42. package/dist/rules/noLocationHeader.js +20 -0
  43. package/dist/rules/noLogModule.js +8 -2
  44. package/dist/rules/noMapReduceSearchResults.js +33 -0
  45. package/dist/rules/noMissingModules.js +29 -0
  46. package/dist/rules/noModuleScopeApiCalls.js +49 -0
  47. package/dist/rules/noMultipleDefine.js +14 -0
  48. package/dist/rules/noNativeIterators.js +19 -0
  49. package/dist/rules/noPositionalOptions.js +26 -0
  50. package/dist/rules/noPostMessageWildcard.js +15 -0
  51. package/dist/rules/noPublicFileUpload.js +20 -0
  52. package/dist/rules/noQueryColumnFieldAndFormula.js +19 -0
  53. package/dist/rules/noRecordInLoop.js +34 -0
  54. package/dist/rules/noRequireInsteadOfDefine.js +25 -0
  55. package/dist/rules/noSecretKeyGuidAndSecret.js +16 -0
  56. package/dist/rules/noSensitiveBrowserStorage.js +24 -0
  57. package/dist/rules/noStackInResponse.js +21 -0
  58. package/dist/rules/noSuiteQLSelectStar.js +18 -0
  59. package/dist/rules/noSuiteQLSystemNoteJoin.js +19 -0
  60. package/dist/rules/noSuiteQLTypeInSearch.js +41 -0
  61. package/dist/rules/noSuiteScript1Api.js +84 -0
  62. package/dist/rules/noUnknownModuleMember.js +23 -0
  63. package/dist/rules/noUnknownOptions.js +31 -0
  64. package/dist/rules/noUnsupportedServerWidgetFieldType.js +40 -0
  65. package/dist/rules/noWeakHash.js +17 -0
  66. package/dist/rules/noWildcardCors.js +16 -0
  67. package/dist/rules/noWrongScriptTypeModules.js +30 -0
  68. package/dist/rules/requireClientValidateReturn.js +27 -0
  69. package/dist/rules/requireCustpagePrefix.js +23 -0
  70. package/dist/rules/requireEachReturn.js +38 -0
  71. package/dist/rules/requireLogModule.js +25 -0
  72. package/dist/rules/requireOptions.js +26 -0
  73. package/dist/rules/requireOrderByForPagedSuiteQL.js +17 -0
  74. package/dist/rules/requireQueryOptions.js +53 -0
  75. package/dist/rules/requireServerResponseOptions.js +33 -0
  76. package/dist/rules/requireServerWidgetOptions.js +37 -0
  77. package/package.json +3 -1
  78. package/dist/rules/noExtraModules.js +0 -23
@@ -0,0 +1,20 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { headerCall } from "../lib/headers.js";
3
+ import { isStaticString } from "../lib/suitescript.js";
4
+ // Tier 0: docs/adr/0005-guessing-rules-are-in-no-preset.md.
5
+ // Oracle's OWASP skill: "SAFE: use the documented redirect module instead of
6
+ // writing raw headers", because an unvalidated value in a response header can
7
+ // split the response. LOW CONFIDENCE, so no preset includes it: the value may
8
+ // have been validated, and the skill's references still set Location after
9
+ // sanitising it.
10
+ export const noLocationHeader = defineRule({
11
+ query: ast("JS_CALL_EXPRESSION"),
12
+ run(node) {
13
+ const header = headerCall(node);
14
+ if (header?.name === "location" &&
15
+ header.value &&
16
+ !isStaticString(header.value)) {
17
+ registerDiagnostic(header.value, "warning", "Use N/redirect instead of writing a Location header from a variable.");
18
+ }
19
+ },
20
+ });
@@ -1,7 +1,13 @@
1
1
  import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
2
  import { dependencies } from "../lib/biome-modules.js";
3
- // Use the global `log` instead of loading N/log. The global is not available in
4
- // client scripts, so scope this rule away from them with the plugin `includes`.
3
+ // Tier 0: docs/adr/0005-guessing-rules-are-in-no-preset.md, docs/adr/0006-log-is-a-global-with-two-opposite-styles.md.
4
+ // Opt-in style: use the global `log` instead of loading N/log. Oracle's help
5
+ // center says "NetSuite loads the log Object by default for all script types. You
6
+ // do not need to load it manually", which is what this rule follows. It is the
7
+ // opposite of requireLogModule, so enable only one of them. (The upgrade skill
8
+ // says the global exists but loading is recommended, which requireLogModule
9
+ // follows; the learning skill says it is not global and loses.) Logging in a client script attached to a form is
10
+ // ignored either way; see noClientLog.
5
11
  export const noLogModule = defineRule({
6
12
  query: ast("JS_CALL_EXPRESSION", "JS_IMPORT"),
7
13
  run(node) {
@@ -0,0 +1,33 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { descendants, enclosingFunctionNames, isFunction, } from "../lib/script-side.js";
3
+ const RESULTS = /\.getRange\(/;
4
+ // Oracle: running a search in getInputData and returning its results risks a
5
+ // timeout; return the search.create()/search.load() object instead. Detects
6
+ // `.getRange()` results returned directly or through a local variable.
7
+ export const noMapReduceSearchResults = defineRule({
8
+ query: ast("JS_RETURN_STATEMENT"),
9
+ run(node) {
10
+ if (!enclosingFunctionNames(node).includes("getInputData"))
11
+ return;
12
+ const argument = node.children()[0];
13
+ if (!argument)
14
+ return;
15
+ let flagged = RESULTS.test(argument.text);
16
+ if (!flagged && argument.kind === "JS_IDENTIFIER_EXPRESSION") {
17
+ const fn = node.ancestors().find(isFunction);
18
+ for (const d of fn ? descendants(fn) : []) {
19
+ if (d.kind !== "JS_VARIABLE_DECLARATOR")
20
+ continue;
21
+ const id = d.children().find((c) => c.kind === "JS_IDENTIFIER_BINDING");
22
+ const init = d
23
+ .children()
24
+ .find((c) => c.kind === "JS_INITIALIZER_CLAUSE");
25
+ if (id?.text === argument.text && init && RESULTS.test(init.text))
26
+ flagged = true;
27
+ }
28
+ }
29
+ if (flagged) {
30
+ registerDiagnostic(argument, "warning", "Return the search object from getInputData instead of its results.");
31
+ }
32
+ },
33
+ });
@@ -0,0 +1,29 @@
1
+ import { defineRule, registerDiagnostic, semantic, } from "@biomejs/runtime/plugin";
2
+ import { MODULES } from "../lib/modules.js";
3
+ import { inSuiteScriptModule } from "../lib/suitescript.js";
4
+ // Tier 0: docs/adr/0006-log-is-a-global-with-two-opposite-styles.md.
5
+ // `log` and `util` are globals in SuiteScript 2.1 ("NetSuite loads the log Object
6
+ // by default for all script types. You do not need to load it manually."), so
7
+ // neither needs loading. requireLogModule is the style that wants N/log loaded.
8
+ const GLOBALS = ["log", "util"];
9
+ const ALIASES = new Map(Object.entries(MODULES)
10
+ .filter(([, identifier]) => !GLOBALS.includes(identifier))
11
+ .map(([name, identifier]) => [identifier, name]));
12
+ // Reports `record.load()` when `record` resolves to no binding, meaning the
13
+ // module was never added to `define()` or imported. Locals that shadow the
14
+ // name resolve to a binding and are left alone.
15
+ export const noMissingModules = defineRule({
16
+ query: semantic("JS_REFERENCE_IDENTIFIER"),
17
+ run(node, { model }) {
18
+ const module = ALIASES.get(node.text);
19
+ if (!module || model.binding(node))
20
+ return;
21
+ const member = node.parent?.parent;
22
+ if (member?.kind !== "JS_STATIC_MEMBER_EXPRESSION" ||
23
+ member.children()[0]?.text !== node.text)
24
+ return;
25
+ if (!inSuiteScriptModule(node))
26
+ return;
27
+ registerDiagnostic(node, "error", `'${node.text}' is used but ${module} is not loaded.`);
28
+ },
29
+ });
@@ -0,0 +1,49 @@
1
+ import { ast, defineRule, registerDiagnostic, } from "@biomejs/runtime/plugin";
2
+ import { pairs, parseDefine } from "../lib/define.js";
3
+ const FUNCTIONS = [
4
+ "JS_FUNCTION_EXPRESSION",
5
+ "JS_ARROW_FUNCTION_EXPRESSION",
6
+ "JS_FUNCTION_DECLARATION",
7
+ "JS_METHOD_OBJECT_MEMBER",
8
+ "JS_METHOD_CLASS_MEMBER",
9
+ ];
10
+ const FACTORIES = /\.(createColumn|createFilter|createSort|createSetting)$/;
11
+ // Oracle's SAFE guide: "All N/ API modules are unavailable during the `define()`
12
+ // callback execution. Calling `runtime.getCurrentScript()`, `record.load()`, or
13
+ // any N/ API at module scope causes deployment failure." Initialize lazily from an
14
+ // entry point instead. N/log is exempt because log is a global. The failure claim
15
+ // comes only from this skill and has not been checked against Oracle's
16
+ // documentation, and its examples are calls that do work (getCurrentScript, record.load).
17
+ // Calls that only build an object (`search.createColumn`, `createFilter`,
18
+ // `createSort`, `createSetting`) are skipped, because nothing says they fail; remove
19
+ // them from FACTORIES if that turns out to be wrong.
20
+ export const noModuleScopeApiCalls = defineRule({
21
+ query: ast("JS_CALL_EXPRESSION"),
22
+ run(node) {
23
+ const def = parseDefine(node);
24
+ if (!def?.body)
25
+ return;
26
+ const aliases = new Set(pairs(def)
27
+ .filter((d) => d.name?.startsWith("N/") && d.name !== "N/log")
28
+ .map((d) => d.param?.text));
29
+ const visit = (n) => {
30
+ if (FUNCTIONS.includes(n.kind))
31
+ return;
32
+ if (n.kind === "JS_CALL_EXPRESSION") {
33
+ const callee = n.children()[0];
34
+ const object = callee?.kind === "JS_STATIC_MEMBER_EXPRESSION"
35
+ ? callee.children()[0]?.text
36
+ : undefined;
37
+ if (object &&
38
+ aliases.has(object) &&
39
+ !FACTORIES.test(callee?.text ?? "")) {
40
+ registerDiagnostic(n, "error", `${callee?.text}() runs at module scope; call it from an entry point.`);
41
+ }
42
+ }
43
+ for (const child of n.children())
44
+ visit(child);
45
+ };
46
+ for (const child of def.body.children())
47
+ visit(child);
48
+ },
49
+ });
@@ -0,0 +1,14 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { descendants } from "../lib/script-side.js";
3
+ // Oracle (entry point script validation): "The script must include one, and only
4
+ // one, properly structured define statement. The script can't include multiple
5
+ // define statements." NetSuite rejects the upload with MULTIPLE_DEFINE_CALLS.
6
+ export const noMultipleDefine = defineRule({
7
+ query: ast("JS_MODULE", "JS_SCRIPT"),
8
+ run(node) {
9
+ const defines = [...descendants(node)].filter((n) => n.kind === "JS_CALL_EXPRESSION" && n.callee?.text === "define");
10
+ for (const extra of defines.slice(1)) {
11
+ registerDiagnostic(extra, "error", "A script can call define() only once.");
12
+ }
13
+ },
14
+ });
@@ -0,0 +1,19 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ // Oracle's SAFE guide: "`function*` generators and `Symbol.iterator` usage passes
3
+ // Jest/Node.js but fails in GraalJS; use standard array methods instead." Its own
4
+ // samples use `for...of`, so that is not flagged. Generator methods (`*foo() {}`)
5
+ // are generators too. The failure claim comes only from this skill and has not been
6
+ // checked against Oracle's documentation.
7
+ export const noNativeIterators = defineRule({
8
+ query: ast("JS_FUNCTION_DECLARATION", "JS_FUNCTION_EXPRESSION", "JS_METHOD_OBJECT_MEMBER", "JS_METHOD_CLASS_MEMBER", "JS_STATIC_MEMBER_EXPRESSION"),
9
+ run(node) {
10
+ if (node.kind === "JS_STATIC_MEMBER_EXPRESSION") {
11
+ if (node.text === "Symbol.iterator") {
12
+ registerDiagnostic(node, "error", "Avoid Symbol.iterator.");
13
+ }
14
+ }
15
+ else if (node.starToken) {
16
+ registerDiagnostic(node, "error", "Avoid generator functions.");
17
+ }
18
+ },
19
+ });
@@ -0,0 +1,26 @@
1
+ import { defineRule, registerDiagnostic, semantic, } from "@biomejs/runtime/plugin";
2
+ import { moduleCall } from "../lib/module-calls.js";
3
+ import { callArguments } from "../lib/options.js";
4
+ const LITERALS = [
5
+ "JS_STRING_LITERAL_EXPRESSION",
6
+ "JS_NUMBER_LITERAL_EXPRESSION",
7
+ "JS_BOOLEAN_LITERAL_EXPRESSION",
8
+ "JS_NULL_LITERAL_EXPRESSION",
9
+ "JS_ARRAY_EXPRESSION",
10
+ ];
11
+ // The type definitions (Tier 2) show that a module-level function takes one
12
+ // options object. Passing a literal instead (`record.load('customer', 1)`) leaves
13
+ // every required option missing at runtime. Only a literal first argument is
14
+ // flagged; a variable may hold the options object.
15
+ export const noPositionalOptions = defineRule({
16
+ query: semantic("JS_CALL_EXPRESSION"),
17
+ run(node, { model }) {
18
+ const call = moduleCall(node, model);
19
+ if (!call || call.alternatives === 0)
20
+ return;
21
+ const first = callArguments(node)[0];
22
+ if (first && LITERALS.includes(first.kind)) {
23
+ registerDiagnostic(first, "error", `${call.callee}() takes an options object, not positional arguments.`);
24
+ }
25
+ },
26
+ });
@@ -0,0 +1,15 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { callArguments, stringValue } from "../lib/options.js";
3
+ // Oracle's OWASP skill: "`postMessage` uses specific origin (never `'*'`)".
4
+ // Sending with a wildcard target origin lets any website receive the message.
5
+ export const noPostMessageWildcard = defineRule({
6
+ query: ast("JS_CALL_EXPRESSION"),
7
+ run(node) {
8
+ if (!/\.postMessage$/.test(node.callee?.text ?? ""))
9
+ return;
10
+ const origin = callArguments(node)[1];
11
+ if (stringValue(origin) === "*") {
12
+ registerDiagnostic(origin ?? node, "warning", "Pass postMessage() a specific target origin, never '*'.");
13
+ }
14
+ },
15
+ });
@@ -0,0 +1,20 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ // Tier 0: docs/adr/0005-guessing-rules-are-in-no-preset.md.
3
+ // Oracle's OWASP skill: uploads are stored in restricted folders and
4
+ // `isOnline: false // Not publicly accessible by default`; its bad example sets
5
+ // `fileObj.isOnline = true; // Publicly accessible!`. LOW CONFIDENCE, so no preset
6
+ // includes it: public assets legitimately set isOnline.
7
+ export const noPublicFileUpload = defineRule({
8
+ query: ast("JS_PROPERTY_OBJECT_MEMBER", "JS_ASSIGNMENT_EXPRESSION"),
9
+ run(node) {
10
+ const [target, value] = node.children();
11
+ const name = node.kind === "JS_ASSIGNMENT_EXPRESSION"
12
+ ? target?.kind === "JS_STATIC_MEMBER_ASSIGNMENT"
13
+ ? target.children()[1]?.text
14
+ : undefined
15
+ : target?.text;
16
+ if (name === "isOnline" && value?.text === "true") {
17
+ registerDiagnostic(value, "warning", "isOnline: true makes the file publicly accessible.");
18
+ }
19
+ },
20
+ });
@@ -0,0 +1,19 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { hasProperty } from "../lib/log.js";
3
+ // Oracle (N/query): a column's `formula` and `fieldId` "cannot be set at the same time."
4
+ export const noQueryColumnFieldAndFormula = defineRule({
5
+ query: ast("JS_OBJECT_EXPRESSION"),
6
+ run(node) {
7
+ if (!hasProperty(node, "fieldId") || !hasProperty(node, "formula"))
8
+ return;
9
+ const inColumn = node.ancestors().some((a) => {
10
+ if (a.kind === "JS_CALL_EXPRESSION")
11
+ return /createColumn$/.test(a.children()[0]?.text ?? "");
12
+ return (a.kind === "JS_PROPERTY_OBJECT_MEMBER" &&
13
+ a.children()[0]?.text === "columns");
14
+ });
15
+ if (inColumn) {
16
+ registerDiagnostic(node, "error", "A query column cannot set both fieldId and formula.");
17
+ }
18
+ },
19
+ });
@@ -0,0 +1,34 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ const LOOPS = [
3
+ "JS_FOR_STATEMENT",
4
+ "JS_FOR_OF_STATEMENT",
5
+ "JS_FOR_IN_STATEMENT",
6
+ "JS_WHILE_STATEMENT",
7
+ "JS_DO_WHILE_STATEMENT",
8
+ ];
9
+ const ITERATORS = /\.(forEach|each|map)$/;
10
+ const LOOKUPS = /^(record\.load|query\.runSuiteQL(Paged)?|search\.(create|load))(\.promise)?$/;
11
+ // Oracle's SAFE guide: "NEVER run queries inside loops" (the N+1 problem) and
12
+ // "Loading records in loops: use searches or bulk operations." Loading a record or
13
+ // running a query or search per iteration burns governance. Only record.load is
14
+ // named for records, so record.create and submitFields are not flagged. Map/Reduce
15
+ // `map` handles one record per call, so keep this rule out of those files.
16
+ export const noRecordInLoop = defineRule({
17
+ query: ast("JS_CALL_EXPRESSION"),
18
+ run(node) {
19
+ if (!LOOKUPS.test(node.callee?.text ?? ""))
20
+ return;
21
+ const inLoop = node.ancestors().some((a) => {
22
+ if (LOOPS.includes(a.kind))
23
+ return true;
24
+ if (a.kind !== "JS_CALL_ARGUMENT_LIST")
25
+ return false;
26
+ const call = a.parent?.parent;
27
+ return (call?.kind === "JS_CALL_EXPRESSION" &&
28
+ ITERATORS.test(call.children()[0]?.text ?? ""));
29
+ });
30
+ if (inLoop) {
31
+ registerDiagnostic(node, "warning", `${node.callee?.text}() inside a loop; fetch what you need with one search or query instead.`);
32
+ }
33
+ },
34
+ });
@@ -0,0 +1,25 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { callArguments } from "../lib/options.js";
3
+ import { descendants } from "../lib/script-side.js";
4
+ // Oracle (entry point script validation): "The following script is invalid
5
+ // because it uses a require statement instead of a define statement. If you try
6
+ // to upload a script that includes a require statement, the system returns the
7
+ // error 'SuiteScript 2.0 entry point scripts must implement one script type
8
+ // function.'" Only a top-level require([...], callback) in a file with no
9
+ // define() is flagged; require inside a define() callback is legitimate.
10
+ export const noRequireInsteadOfDefine = defineRule({
11
+ query: ast("JS_MODULE", "JS_SCRIPT"),
12
+ run(node) {
13
+ const calls = [...descendants(node)].filter((n) => n.kind === "JS_CALL_EXPRESSION");
14
+ if (calls.some((c) => c.callee?.text === "define"))
15
+ return;
16
+ for (const call of calls) {
17
+ if (call.callee?.text !== "require")
18
+ continue;
19
+ const [deps, callback] = callArguments(call);
20
+ if (deps?.kind === "JS_ARRAY_EXPRESSION" && callback) {
21
+ registerDiagnostic(call, "error", "Use define() instead of require(); NetSuite rejects an entry point script without a define call.");
22
+ }
23
+ }
24
+ },
25
+ });
@@ -0,0 +1,16 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { definedOptions } from "../lib/required-options.js";
3
+ // Oracle (https.createSecretKey): "You cannot use both the options.guid parameter
4
+ // and secret parameter in combination." The type definitions (N/https.d.ts) carry
5
+ // the same comment and make both options optional.
6
+ export const noSecretKeyGuidAndSecret = defineRule({
7
+ query: ast("JS_CALL_EXPRESSION"),
8
+ run(node) {
9
+ if (!/^https\.createSecretKey(\.promise)?$/.test(node.callee?.text ?? ""))
10
+ return;
11
+ const defined = definedOptions(node);
12
+ if (defined?.has("guid") && defined.has("secret")) {
13
+ registerDiagnostic(node, "error", "Pass createSecretKey() either 'guid' or 'secret', not both.");
14
+ }
15
+ },
16
+ });
@@ -0,0 +1,24 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { callArguments, stringValue } from "../lib/options.js";
3
+ import { scriptSide } from "../lib/script-side.js";
4
+ const SENSITIVE = /token|password|secret|api[_-]?key|ssn|credit|card|auth/i;
5
+ // Tier 0: docs/adr/0005-guessing-rules-are-in-no-preset.md.
6
+ // Oracle's OWASP skill: "`localStorage` and `sessionStorage` are accessible to any
7
+ // JavaScript running on the same origin. If an XSS vulnerability exists, stored
8
+ // tokens, PII, or session data can be exfiltrated", and "Never store tokens or PII
9
+ // in localStorage". LOW CONFIDENCE, so no preset includes it: it guesses from the
10
+ // key name.
11
+ export const noSensitiveBrowserStorage = defineRule({
12
+ query: ast("JS_CALL_EXPRESSION"),
13
+ run(node) {
14
+ if (!/^(localStorage|sessionStorage)\.setItem$/.test(node.callee?.text ?? ""))
15
+ return;
16
+ if (scriptSide(node) === "server")
17
+ return;
18
+ const key = callArguments(node)[0];
19
+ const name = stringValue(key);
20
+ if (name && SENSITIVE.test(name)) {
21
+ registerDiagnostic(key ?? node, "warning", `Do not keep '${name}' in browser storage; any script on the page can read it.`);
22
+ }
23
+ },
24
+ });
@@ -0,0 +1,21 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { descendants } from "../lib/script-side.js";
3
+ // Tier 0: docs/adr/0005-guessing-rules-are-in-no-preset.md.
4
+ // Oracle's OWASP skill: "Returning stack traces, internal IDs, script file paths,
5
+ // or record structure details in error responses gives attackers a map of the
6
+ // system." LOW CONFIDENCE, so no preset includes it: only a `.stack` read inside
7
+ // the arguments of `response.write`/`writeLine` is detected, not one returned from
8
+ // a RESTlet.
9
+ export const noStackInResponse = defineRule({
10
+ query: ast("JS_CALL_EXPRESSION"),
11
+ run(node) {
12
+ if (!/response\.(write|writeLine)$/i.test(node.callee?.text ?? ""))
13
+ return;
14
+ for (const child of descendants(node)) {
15
+ if (child.kind === "JS_STATIC_MEMBER_EXPRESSION" &&
16
+ child.children()[1]?.text === "stack") {
17
+ registerDiagnostic(child, "warning", "Do not send a stack trace to the client.");
18
+ }
19
+ }
20
+ },
21
+ });
@@ -0,0 +1,18 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { SUITEQL, staticSuiteql } from "../lib/suiteql.js";
3
+ // Tier 0: docs/adr/0005-guessing-rules-are-in-no-preset.md.
4
+ // Oracle's SAFE guide: "Avoid SELECT *" and "Always specify needed columns."
5
+ // LOW CONFIDENCE, so no preset includes it: the guide itself recommends a
6
+ // `SELECT * ... WHERE ROWNUM <= 1` probe to discover column names, and the rule
7
+ // only sees a query built from literals.
8
+ export const noSuiteQLSelectStar = defineRule({
9
+ query: ast("JS_CALL_EXPRESSION"),
10
+ run(node) {
11
+ if (!SUITEQL.test(node.callee?.text ?? ""))
12
+ return;
13
+ const sql = staticSuiteql(node);
14
+ if (sql && /\bSELECT\s+(DISTINCT\s+)?\*/i.test(sql)) {
15
+ registerDiagnostic(node, "warning", "Name the columns instead of SELECT *.");
16
+ }
17
+ },
18
+ });
@@ -0,0 +1,19 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { SUITEQL, staticSuiteql } from "../lib/suiteql.js";
3
+ // Tier 0: docs/adr/0005-guessing-rules-are-in-no-preset.md.
4
+ // Oracle's SAFE guide: "`SystemNote` is an extremely high-volume table ... Joining
5
+ // it in SuiteQL queries causes severe performance degradation and frequent query
6
+ // timeouts", "never as a JOIN against a large result set". LOW CONFIDENCE, so no
7
+ // preset includes it: a tightly filtered join is fine, and the rule only sees a
8
+ // query built from literals.
9
+ export const noSuiteQLSystemNoteJoin = defineRule({
10
+ query: ast("JS_CALL_EXPRESSION"),
11
+ run(node) {
12
+ if (!SUITEQL.test(node.callee?.text ?? ""))
13
+ return;
14
+ const sql = staticSuiteql(node);
15
+ if (sql && /\b(JOIN\s+|,\s*)SystemNote\b/i.test(sql)) {
16
+ registerDiagnostic(node, "warning", "Do not join SystemNote in a SuiteQL query; query it separately with filters.");
17
+ }
18
+ },
19
+ });
@@ -0,0 +1,41 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ import { optionsObject, propertyValue, stringValue } from "../lib/options.js";
3
+ // SuiteQL abbreviations that are not search types. `Estimate` and `CashSale` are
4
+ // left out: their lowercase form is a valid search type.
5
+ const ABBREVIATIONS = new Set([
6
+ "PurchOrd",
7
+ "SalesOrd",
8
+ "CustInvc",
9
+ "ItemRcpt",
10
+ "ItemShip",
11
+ "VendBill",
12
+ "VendPymt",
13
+ "CustPymt",
14
+ "Journal",
15
+ "CustCred",
16
+ "RtnAuth",
17
+ "TrnfrOrd",
18
+ "WorkOrd",
19
+ "ExpRept",
20
+ "InvAdjst",
21
+ "VendCred",
22
+ ]);
23
+ // Oracle's SAFE guide: "Passing a SuiteQL abbreviation to `search.create()` throws
24
+ // a runtime error: `The record type [PURCHORD] is invalid.` Always use
25
+ // `search.Type.*` enum values (or their lowercase string equivalents) in N/search,
26
+ // and SuiteQL abbreviations only inside SuiteQL query strings." The failure claim
27
+ // comes only from this skill and has not been checked against Oracle's
28
+ // documentation."
29
+ export const noSuiteQLTypeInSearch = defineRule({
30
+ query: ast("JS_CALL_EXPRESSION"),
31
+ run(node) {
32
+ if (!/^search\.create(\.promise)?$/.test(node.callee?.text ?? ""))
33
+ return;
34
+ const options = optionsObject(node);
35
+ const type = options && propertyValue(options, "type");
36
+ const value = stringValue(type);
37
+ if (type && value && ABBREVIATIONS.has(value)) {
38
+ registerDiagnostic(type, "error", `'${value}' is a SuiteQL abbreviation, not a search type; use search.Type.`);
39
+ }
40
+ },
41
+ });
@@ -0,0 +1,84 @@
1
+ import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin";
2
+ // SuiteScript 2.1 only: nlapiXxx functions and nlobjXxx objects are 1.0 leftovers.
3
+ // Oracle's upgrade skill lists "Any `nlapi[A-Z]` function call" and "Any
4
+ // `nlobj[A-Z]` constructor or instanceof" as Critical, and also "Old getter/setter
5
+ // methods" (`.getFieldValue()`, `.setFieldValue()`) and "Old error handling"
6
+ // (`e.getCode()`). Only method names that exist in no 2.1 module are flagged, so a
7
+ // 2.1 method of the same name on another object is never hit.
8
+ const GLOBAL_OBJECTS = ["window", "globalThis", "self", "top", "this"];
9
+ const V1_METHODS = new Set([
10
+ "getLineItemCount",
11
+ "getLineItemValue",
12
+ "getLineItemValues",
13
+ "getLineItemText",
14
+ "getLineItemField",
15
+ "setLineItemValue",
16
+ "setLineItemValues",
17
+ "insertLineItem",
18
+ "removeLineItem",
19
+ "selectLineItem",
20
+ "selectNewLineItem",
21
+ "commitLineItem",
22
+ "findLineItemValue",
23
+ "getCurrentLineItemValue",
24
+ "getCurrentLineItemValues",
25
+ "setCurrentLineItemValue",
26
+ "getFieldValue",
27
+ "getFieldValues",
28
+ "setFieldValue",
29
+ "setFieldValues",
30
+ "getFieldText",
31
+ "getFieldTexts",
32
+ "setFieldText",
33
+ "setFieldTexts",
34
+ "addSubList",
35
+ "getSubList",
36
+ "insertSubList",
37
+ "addSubTab",
38
+ "getSubTab",
39
+ "insertSubTab",
40
+ ]);
41
+ const V1_ERROR_METHODS = new Set(["getCode", "getDetails", "getStackTrace"]);
42
+ const V1_NAME = /^(nlapi|nlobj)[A-Z]/;
43
+ export const noSuiteScript1Api = defineRule({
44
+ query: ast("JS_REFERENCE_IDENTIFIER", "JS_STATIC_MEMBER_EXPRESSION", "JS_CALL_EXPRESSION"),
45
+ run(node) {
46
+ if (node.kind === "JS_REFERENCE_IDENTIFIER") {
47
+ if (V1_NAME.test(node.text)) {
48
+ registerDiagnostic(node, "error", `'${node.text}' is SuiteScript 1.0; use the SuiteScript 2.1 N/ modules.`);
49
+ }
50
+ return;
51
+ }
52
+ if (node.kind === "JS_STATIC_MEMBER_EXPRESSION") {
53
+ const [object, member] = node.children();
54
+ if (object &&
55
+ member &&
56
+ GLOBAL_OBJECTS.includes(object.text) &&
57
+ V1_NAME.test(member.text)) {
58
+ registerDiagnostic(node, "error", `'${member.text}' is SuiteScript 1.0; use the SuiteScript 2.1 N/ modules.`);
59
+ }
60
+ return;
61
+ }
62
+ const callee = node.callee;
63
+ if (callee?.kind !== "JS_STATIC_MEMBER_EXPRESSION")
64
+ return;
65
+ const [object, member] = callee.children();
66
+ if (!object || !member)
67
+ return;
68
+ if (V1_METHODS.has(member.text)) {
69
+ registerDiagnostic(node, "error", `'${member.text}()' is a SuiteScript 1.0 method; use the SuiteScript 2.1 equivalent.`);
70
+ }
71
+ else if (V1_ERROR_METHODS.has(member.text) &&
72
+ isCatchParameter(node, object.text)) {
73
+ registerDiagnostic(node, "error", `'${member.text}()' is SuiteScript 1.0; in 2.1 the error exposes a property.`);
74
+ }
75
+ },
76
+ });
77
+ function isCatchParameter(node, name) {
78
+ for (const ancestor of node.ancestors()) {
79
+ if (ancestor.kind === "JS_CATCH_CLAUSE") {
80
+ return new RegExp(`^catch\\s*\\(\\s*${name}\\b`).test(ancestor.text);
81
+ }
82
+ }
83
+ return false;
84
+ }
@@ -0,0 +1,23 @@
1
+ import { defineRule, registerDiagnostic, semantic, } from "@biomejs/runtime/plugin";
2
+ import { moduleOfReceiver, typingsOf } from "../lib/module-calls.js";
3
+ const IGNORED = ["default", "prototype", "constructor", "__esModule"];
4
+ // Reading something the type definitions (Tier 2) do not export from an N/ module
5
+ // (`record.loadRecord`) gives undefined or throws at runtime. The module is
6
+ // resolved from the define() or import binding of the receiver. Modules that
7
+ // re-export everything are skipped because their exports cannot be listed, and the
8
+ // definitions can lag a release, so a member Oracle added recently may be reported.
9
+ export const noUnknownModuleMember = defineRule({
10
+ query: semantic("JS_STATIC_MEMBER_EXPRESSION"),
11
+ run(node, { model }) {
12
+ const [object, member] = node.children();
13
+ if (!object || !member)
14
+ return;
15
+ const module = moduleOfReceiver(object, model);
16
+ const exports = typingsOf(module)?.exports;
17
+ if (exports &&
18
+ !exports.includes(member.text) &&
19
+ !IGNORED.includes(member.text)) {
20
+ registerDiagnostic(member, "error", `${module} has no export '${member.text}'.`);
21
+ }
22
+ },
23
+ });
@@ -0,0 +1,31 @@
1
+ import { defineRule, registerDiagnostic, semantic, } from "@biomejs/runtime/plugin";
2
+ import { moduleCall } from "../lib/module-calls.js";
3
+ import { objectMembers, optionsObject } from "../lib/options.js";
4
+ // An option the type definitions (Tier 2) do not list for a module-level function
5
+ // is ignored at runtime, so a typo in an optional option goes unnoticed. A typo in
6
+ // a required option is also caught by requireOptions. Functions whose options
7
+ // type accepts any key are skipped.
8
+ export const noUnknownOptions = defineRule({
9
+ query: semantic("JS_CALL_EXPRESSION"),
10
+ run(node, { model }) {
11
+ const call = moduleCall(node, model);
12
+ if (!call || call.alternatives === 0)
13
+ return;
14
+ if (call.alternatives.some((alt) => alt[2]))
15
+ return;
16
+ const options = optionsObject(node);
17
+ if (!options)
18
+ return;
19
+ const known = new Set(call.alternatives.flatMap(([, names]) => names));
20
+ for (const member of objectMembers(options)) {
21
+ if (member.kind === "JS_SPREAD")
22
+ return;
23
+ if (member.kind !== "JS_PROPERTY_OBJECT_MEMBER")
24
+ continue;
25
+ const name = member.children()[0]?.text.replace(/^['"]|['"]$/g, "");
26
+ if (name && !known.has(name)) {
27
+ registerDiagnostic(member, "warning", `${call.callee}() does not take an '${name}' option.`);
28
+ }
29
+ }
30
+ },
31
+ });