diff --git a/src/detectors/BaseReporter.ts b/src/detectors/BaseReporter.ts index 77f3173a0..7682fa1e7 100644 --- a/src/detectors/BaseReporter.ts +++ b/src/detectors/BaseReporter.ts @@ -1,5 +1,6 @@ import type ts from "typescript"; import type {LintMessage, CoverageInfo, LintResult} from "../detectors/AbstractDetector.js"; +import type {Tag as SaxTag} from "sax-wasm"; export interface BaseReporter { addMessage(args: ReporterMessage): void; @@ -8,7 +9,7 @@ export interface BaseReporter { } export interface ReporterMessage { - node?: ts.Node | string; + node?: ts.Node | SaxTag | string; message: LintMessage["message"]; messageDetails?: LintMessage["messageDetails"]; severity: LintMessage["severity"]; diff --git a/src/detectors/transpilers/html/parser.ts b/src/detectors/transpilers/html/parser.ts new file mode 100644 index 000000000..b63211bb2 --- /dev/null +++ b/src/detectors/transpilers/html/parser.ts @@ -0,0 +1,76 @@ +import type {ReadStream} from "node:fs"; +import {Detail, SaxEventType, SAXParser, Tag as SaxTag} from "sax-wasm"; +import {finished} from "node:stream/promises"; +import fs from "node:fs/promises"; +import {createRequire} from "node:module"; +const require = createRequire(import.meta.url); + +let saxWasmBuffer: Buffer; +async function initSaxWasm() { + if (!saxWasmBuffer) { + const saxPath = require.resolve("sax-wasm/lib/sax-wasm.wasm"); + saxWasmBuffer = await fs.readFile(saxPath); + } + + return saxWasmBuffer; +} + +async function parseHtml(contentStream: ReadStream, parseHandler: (type: SaxEventType, tag: Detail) => void) { + const options = {highWaterMark: 32 * 1024}; // 32k chunks + const saxWasmBuffer = await initSaxWasm(); + const saxParser = new SAXParser(SaxEventType.CloseTag, options); + + saxParser.eventHandler = parseHandler; + + // Instantiate and prepare the wasm for parsing + if (!await saxParser.prepareWasm(saxWasmBuffer)) { + throw new Error("Unknown error during WASM Initialization"); + } + + // stream from a file in the current directory + contentStream.on("data", (chunk: Uint8Array) => { + try { + saxParser.write(chunk); + } catch (err) { + if (err instanceof Error) { + // In case of an error, destroy the content stream to make the + // error bubble up to our callers + contentStream.destroy(err); + } else { + throw err; + } + } + }); + await finished(contentStream); + saxParser.end(); +} + +export async function extractJSScriptTags(contentStream: ReadStream) { + const scriptTags: SaxTag[] = []; + + await parseHtml(contentStream, (event, tag) => { + if (tag instanceof SaxTag && + event === SaxEventType.CloseTag && + tag.value === "script") { + const isJSScriptTag = tag.attributes.every((attr) => { + // The "type" attribute of the script tag should be + // 1. not set (default), + // 2. an empty string, + // 3. or a JavaScript MIME type (text/javascript) + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type#attribute_is_not_set_default_an_empty_string_or_a_javascript_mime_type + return attr.name.value !== "type" || + (attr.name.value === "type" && + ["", + "text/javascript", + "application/javascript", /* legacy */ + ].includes(attr.value.value.toLowerCase())); + }); + + if (isJSScriptTag) { + scriptTags.push(tag); + } + } + }); + + return scriptTags; +} diff --git a/src/detectors/typeChecker/index.ts b/src/detectors/typeChecker/index.ts index 869937993..16f8e98cc 100644 --- a/src/detectors/typeChecker/index.ts +++ b/src/detectors/typeChecker/index.ts @@ -9,6 +9,7 @@ import {taskStart} from "../util/perf.js"; import {amdToEsm} from "../transpilers/amd/transpiler.js"; import {xmlToJs} from "../transpilers/xml/transpiler.js"; import {lintManifest} from "../../linter/json/linter.js"; +import {lintHtml} from "../../linter/html/linter.js"; import { FileBasedDetector, LintMessage, LintMessageSeverity, LintResult, ProjectBasedDetector, } from "../AbstractDetector.js"; @@ -116,6 +117,13 @@ export class TsProjectDetector extends ProjectBasedDetector { resourcePath = resourcePath.replace(/\.json$/, ".js"); const resourceContent = await resource.getString(); ({source, messages} = await lintManifest(resourcePath, resourceContent)); + } else if (resourcePath.endsWith(".html")) { + resourcePath = resourcePath.replace(/\.html$/, ".jsx"); + // TODO: Enable when implement script extraction and parse + // Details: TS treats HTML as JSX, but parsing results are not consistent. https://github.com/SAP/ui5-linter/pull/48#discussion_r1551412367 + // source = await resource.getString(); + source = ""; + ({messages} = await lintHtml(resourcePath, resource.getStream())); } else { throw new Error(`Unsupported file type for ${resourcePath}`); } @@ -159,7 +167,7 @@ export class TsProjectDetector extends ProjectBasedDetector { // Read all resources and test-resources and their content since tsc works completely synchronous const globEnd = taskStart("Locating Resources"); - const fileTypes = "{*.js,*.view.xml,*.fragment.xml,manifest.json}"; + const fileTypes = "{*.js,*.view.xml,*.fragment.xml,manifest.json,*.html}"; const allResources = await reader.byGlob("/resources/**/" + fileTypes); const allTestResources = await reader.byGlob("/test-resources/**/" + fileTypes); globEnd(); @@ -212,11 +220,20 @@ export class TsProjectDetector extends ProjectBasedDetector { }); // Rewrite fs-paths to virtual paths - resourcePaths = allResources.map((res: Resource) => { - if (absoluteFilePaths.includes(res.getSourceMetadata().fsPath)) { - return res.getPath(); + resourcePaths = [...allResources, ...allTestResources].map((res: Resource) => { + if (!absoluteFilePaths.includes(res.getSourceMetadata().fsPath)) { + return; } - }).filter(($: string | undefined) => $); + + let resPath = res.getPath(); + if (resPath.endsWith(".html")) { + resPath = resPath.replace(/\.[a-z]+$/, ".jsx"); + } else if (!resPath.endsWith(".js")) { + resPath = resPath.replace(/\.[a-z]+$/, ".js"); + } + return resPath; + }) + .filter(($: string | undefined) => $); } else { resourcePaths = Array.from(resources.keys()); } @@ -290,6 +307,17 @@ export class TsFileDetector extends FileBasedDetector { } internalfilePath = internalfilePath.replace(/\.json$/, ".js"); transformationResult = await lintManifest(filePath.replace(/\.json$/, ".js"), fileContent); + } else if (filePath.endsWith(".html")) { + // TODO: Enable when implement script extraction and parse + // Details: TS treats HTML as JSX, but parsing results are not consistent. https://github.com/SAP/ui5-linter/pull/48#discussion_r1551412367 + // const fileContent = ts.sys.readFile(filePath); + // if (!fileContent) { + // throw new Error(`Failed to read file ${filePath}`); + // } + internalfilePath = internalfilePath.replace(/\.html$/, ".jsx"); + transformationResult = await lintHtml(path.basename(filePath), fs.createReadStream(filePath)); + // transformationResult.source = fileContent; + transformationResult.source = ""; } else { throw new Error(`Unsupported file type for ${filePath}`); } diff --git a/src/linter/html/HtmlReporter.ts b/src/linter/html/HtmlReporter.ts new file mode 100644 index 000000000..a9705999b --- /dev/null +++ b/src/linter/html/HtmlReporter.ts @@ -0,0 +1,84 @@ +import type {BaseReporter, ReporterMessage, ReporterCoverageInfo} from "../../detectors/BaseReporter.js"; +import type {LintMessage} from "../../detectors/AbstractDetector.js"; +import {Tag as SaxTag} from "sax-wasm"; +import {LintMessageSeverity, CoverageInfo} from "../../detectors/AbstractDetector.js"; +import {resolveLinks} from "../../formatter/lib/resolveLinks.js"; + +export default class HtmlReporter implements BaseReporter { + #filePath: string; + #messages: LintMessage[] = []; + #coverageInfo: CoverageInfo[] = []; + + constructor(filePath: string) { + this.#filePath = filePath; + } + + addMessage({node, message, messageDetails, severity, ruleId, fatal = undefined}: ReporterMessage) { + if (fatal && severity !== LintMessageSeverity.Error) { + throw new Error(`Reports flagged as "fatal" must be of severity "Error"`); + } + + let line = 0, column = 0; + if (node instanceof SaxTag) { + ({line, character: column} = node.openStart); + } + + const msg: LintMessage = { + ruleId, + severity, + fatal, + line: line + 1, + column: column + 1, + message, + }; + + if (messageDetails) { + msg.messageDetails = resolveLinks(messageDetails); + } + + this.#messages.push(msg); + } + + addCoverageInfo({node, message, category}: ReporterCoverageInfo) { + let line = 0, column = 0, endLine = 0, endColumn = 0; + if (node instanceof SaxTag) { + ({line, character: column} = node.openStart); + ({line: endLine, character: endColumn} = node.closeEnd); + } + + this.#coverageInfo.push({ + category, + // One-based to be aligned with most IDEs + line: line + 1, + column: column + 1, + endLine: endLine + 1, + endColumn: endColumn + 1, + message, + }); + } + + getReport() { + let errorCount = 0; + let warningCount = 0; + let fatalErrorCount = 0; + for (const {severity, fatal} of this.#messages) { + if (severity === LintMessageSeverity.Error) { + errorCount++; + if (fatal) { + fatalErrorCount++; + } + } else { + warningCount++; + } + } + + return { + filePath: this.#filePath, + messages: this.#messages, + coverageInfo: this.#coverageInfo, + errorCount, + warningCount, + fatalErrorCount, + }; + } +} diff --git a/src/linter/html/linter.ts b/src/linter/html/linter.ts new file mode 100644 index 000000000..6dd781aea --- /dev/null +++ b/src/linter/html/linter.ts @@ -0,0 +1,32 @@ +import {taskStart} from "../../detectors/util/perf.js"; +import {extractJSScriptTags} from "../../detectors/transpilers/html/parser.js"; +import {LintMessageSeverity} from "../../detectors/AbstractDetector.js"; +import HtmlReporter from "./HtmlReporter.js"; + +import type {TranspileResult} from "../../detectors/transpilers/AbstractTranspiler.js"; +import type {ReadStream} from "node:fs"; + +export async function lintHtml(resourceName: string, contentStream: ReadStream): Promise { + const taskLintEnd = taskStart("Linting HTML", resourceName); + const report = new HtmlReporter(resourceName); + const jsScriptTags = await extractJSScriptTags(contentStream); + + jsScriptTags.forEach((tag) => { + const scriptContent = tag.textNodes?.map((tNode) => tNode.value).join("").trim(); + + if (scriptContent) { + report.addMessage({ + node: tag, + severity: LintMessageSeverity.Warning, + ruleId: "ui5-linter-csp-unsafe-inline-script", + message: `Use of unsafe inline script`, + messageDetails: "{@link topic:fe1a6dba940e479fb7c3bc753f92b28c Content Security Policy}", + }); + } + }); + + taskLintEnd(); + + const {messages} = report.getReport(); + return {messages, source: "", map: ""}; +} diff --git a/test/fixtures/linter/rules/CSPCompliance/NoInlineJS.html b/test/fixtures/linter/rules/CSPCompliance/NoInlineJS.html new file mode 100644 index 000000000..234152a16 --- /dev/null +++ b/test/fixtures/linter/rules/CSPCompliance/NoInlineJS.html @@ -0,0 +1,48 @@ + + + + + No inline JS + + + + + + + + + + + + + diff --git a/test/fixtures/linter/rules/CSPCompliance/NoInlineJS_negative.html b/test/fixtures/linter/rules/CSPCompliance/NoInlineJS_negative.html new file mode 100644 index 000000000..2433e0f52 --- /dev/null +++ b/test/fixtures/linter/rules/CSPCompliance/NoInlineJS_negative.html @@ -0,0 +1,32 @@ + + + + + No inline JS + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/lib/detectors/transpilers/xml/snapshots/transpiler.ts.snap b/test/lib/detectors/transpilers/xml/snapshots/transpiler.ts.snap index 33cb3f6fc..34cf2d7d2 100644 Binary files a/test/lib/detectors/transpilers/xml/snapshots/transpiler.ts.snap and b/test/lib/detectors/transpilers/xml/snapshots/transpiler.ts.snap differ diff --git a/test/lib/linter/_linterHelper.ts b/test/lib/linter/_linterHelper.ts index 21bcc1b21..1054eae81 100644 --- a/test/lib/linter/_linterHelper.ts +++ b/test/lib/linter/_linterHelper.ts @@ -65,8 +65,11 @@ export function createTestsForFixtures(fixturesPath: string) { throw new Error(`Failed to find any fixtures in directory ${fixturesPath}`); } for (const fileName of testFiles) { - if (!fileName.endsWith(".js") && !fileName.endsWith(".xml") && !fileName.endsWith(".json")) { - // Ignore non-JavaScript, non-XML and non-JSON files + if (!fileName.endsWith(".js") && + !fileName.endsWith(".xml") && + !fileName.endsWith(".json") && + !fileName.endsWith(".html")) { + // Ignore non-JavaScript, non-XML, non-JSON and non-HTML files continue; } diff --git a/test/lib/linter/rules/CSPCompliance.ts b/test/lib/linter/rules/CSPCompliance.ts new file mode 100644 index 000000000..57b18a4f6 --- /dev/null +++ b/test/lib/linter/rules/CSPCompliance.ts @@ -0,0 +1,10 @@ +import path from "node:path"; +import {fileURLToPath} from "node:url"; +import {createTestsForFixtures} from "../_linterHelper.js"; + +const filePath = fileURLToPath(import.meta.url); +const __dirname = path.dirname(filePath); +const fileName = path.basename(filePath, ".ts"); +const fixturesPath = path.join(__dirname, "..", "..", "..", "fixtures", "linter", "rules", fileName); + +createTestsForFixtures(fixturesPath); diff --git a/test/lib/linter/rules/snapshots/CSPCompliance.ts.md b/test/lib/linter/rules/snapshots/CSPCompliance.ts.md new file mode 100644 index 000000000..af1750ae8 --- /dev/null +++ b/test/lib/linter/rules/snapshots/CSPCompliance.ts.md @@ -0,0 +1,72 @@ +# Snapshot report for `test/lib/linter/rules/CSPCompliance.ts` + +The actual snapshot is saved in `CSPCompliance.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## General: NoInlineJS.html + +> Snapshot 1 + + [ + { + coverageInfo: [], + errorCount: 0, + fatalErrorCount: 0, + filePath: 'NoInlineJS.html', + messages: [ + { + column: 2, + fatal: undefined, + line: 9, + message: 'Use of unsafe inline script', + messageDetails: 'Content Security Policy (https://ui5.sap.com/1.120/#/topic/fe1a6dba940e479fb7c3bc753f92b28c)', + ruleId: 'ui5-linter-csp-unsafe-inline-script', + severity: 1, + }, + { + column: 2, + fatal: undefined, + line: 17, + message: 'Use of unsafe inline script', + messageDetails: 'Content Security Policy (https://ui5.sap.com/1.120/#/topic/fe1a6dba940e479fb7c3bc753f92b28c)', + ruleId: 'ui5-linter-csp-unsafe-inline-script', + severity: 1, + }, + { + column: 2, + fatal: undefined, + line: 25, + message: 'Use of unsafe inline script', + messageDetails: 'Content Security Policy (https://ui5.sap.com/1.120/#/topic/fe1a6dba940e479fb7c3bc753f92b28c)', + ruleId: 'ui5-linter-csp-unsafe-inline-script', + severity: 1, + }, + { + column: 2, + fatal: undefined, + line: 31, + message: 'Use of unsafe inline script', + messageDetails: 'Content Security Policy (https://ui5.sap.com/1.120/#/topic/fe1a6dba940e479fb7c3bc753f92b28c)', + ruleId: 'ui5-linter-csp-unsafe-inline-script', + severity: 1, + }, + ], + warningCount: 4, + }, + ] + +## General: NoInlineJS_negative.html + +> Snapshot 1 + + [ + { + coverageInfo: [], + errorCount: 0, + fatalErrorCount: 0, + filePath: 'NoInlineJS_negative.html', + messages: [], + warningCount: 0, + }, + ] diff --git a/test/lib/linter/rules/snapshots/CSPCompliance.ts.snap b/test/lib/linter/rules/snapshots/CSPCompliance.ts.snap new file mode 100644 index 000000000..4d46aa976 Binary files /dev/null and b/test/lib/linter/rules/snapshots/CSPCompliance.ts.snap differ diff --git a/test/lib/linter/rules/snapshots/NoDeprecatedApi.ts.snap b/test/lib/linter/rules/snapshots/NoDeprecatedApi.ts.snap index 11120fef8..5f63420bc 100644 Binary files a/test/lib/linter/rules/snapshots/NoDeprecatedApi.ts.snap and b/test/lib/linter/rules/snapshots/NoDeprecatedApi.ts.snap differ diff --git a/test/lib/linter/rules/snapshots/NoGlobals.ts.snap b/test/lib/linter/rules/snapshots/NoGlobals.ts.snap index c0d1f72e1..9401ec6ef 100644 Binary files a/test/lib/linter/rules/snapshots/NoGlobals.ts.snap and b/test/lib/linter/rules/snapshots/NoGlobals.ts.snap differ diff --git a/test/lib/linter/snapshots/linter.ts.md b/test/lib/linter/snapshots/linter.ts.md index 6552b2386..b3a17d44e 100644 --- a/test/lib/linter/snapshots/linter.ts.md +++ b/test/lib/linter/snapshots/linter.ts.md @@ -363,6 +363,22 @@ Generated by [AVA](https://avajs.dev). messages: [], warningCount: 0, }, + { + coverageInfo: [], + errorCount: 0, + fatalErrorCount: 0, + filePath: 'webapp/index-cdn.html', + messages: [], + warningCount: 0, + }, + { + coverageInfo: [], + errorCount: 0, + fatalErrorCount: 0, + filePath: 'webapp/index.html', + messages: [], + warningCount: 0, + }, { coverageInfo: [], errorCount: 5, @@ -561,6 +577,14 @@ Generated by [AVA](https://avajs.dev). messages: [], warningCount: 0, }, + { + coverageInfo: [], + errorCount: 0, + fatalErrorCount: 0, + filePath: 'webapp/test/integration/opaTests.qunit.html', + messages: [], + warningCount: 0, + }, { coverageInfo: [], errorCount: 2, @@ -647,6 +671,14 @@ Generated by [AVA](https://avajs.dev). messages: [], warningCount: 0, }, + { + coverageInfo: [], + errorCount: 0, + fatalErrorCount: 0, + filePath: 'webapp/test/testsuite.qunit.html', + messages: [], + warningCount: 0, + }, { coverageInfo: [ { @@ -676,6 +708,14 @@ Generated by [AVA](https://avajs.dev). messages: [], warningCount: 0, }, + { + coverageInfo: [], + errorCount: 0, + fatalErrorCount: 0, + filePath: 'webapp/test/unit/unitTests.qunit.html', + messages: [], + warningCount: 0, + }, { coverageInfo: [], errorCount: 2, @@ -943,6 +983,14 @@ Generated by [AVA](https://avajs.dev). ], warningCount: 0, }, + { + coverageInfo: [], + errorCount: 0, + fatalErrorCount: 0, + filePath: 'src/test/js/Example.html', + messages: [], + warningCount: 0, + }, { coverageInfo: [ { diff --git a/test/lib/linter/snapshots/linter.ts.snap b/test/lib/linter/snapshots/linter.ts.snap index f7ba49539..9fa4df8e1 100644 Binary files a/test/lib/linter/snapshots/linter.ts.snap and b/test/lib/linter/snapshots/linter.ts.snap differ