Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to traust-engine are documented here.

## [0.47.0]

- **TypeScript/JavaScript rule tranche (rule pack v1.11).** New rules for the
largest uncovered TS/JS clusters in the confirmed-finding ledger: XSS
(`dangerouslySetInnerHTML`, DOM HTML sinks, `rehype-raw` without
sanitizing, reflected Express responses), path traversal (request values to
fs paths, `sendFile`, `download`) and open redirect (server redirects and
client navigation from address-bar values); SSRF gains http(s).request,
the remaining axios methods, got, superagent, needle and undici sinks. The
same rules ship on `development` (0.40.0).

## [0.46.0]

- **Code-verified versions can count as affected, with evidence.**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.46.0
0.47.0
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "traust-engine"
version = "0.46.0"
version = "0.47.0"
license = "Apache-2.0"
description = "Processing core for the AI security harness: scanners, data management, analysis, format handlers"
requires-python = ">=3.11"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,41 @@ bound text, so a "contains" test needs a leading `.*` (`(?is).*\blike\b`),
and it cannot sit directly under `pattern-either` — wrap each branch in
its own `patterns:`.


v1.11 (2026-10-27): **TypeScript/JavaScript tranche, from the ledger.** The
rule-mining report's largest uncovered TS/JS clusters were XSS (CWE-79, 37
confirmed findings in about 30 repositories), path traversal (CWE-22, 10)
and open redirect (CWE-601, 4); each rule is written from the cited
findings' evidence. Test, spec, mock and mock-backend files are excluded.
- **XSS (`typescript/cross-site-scripting.yaml`).**
- `xss-dangerously-set-inner-html`: a computed `__html` value with no
sanitizer. Translation strings (`t(...)`, `i18n*`) and values named as
sanitized are not matched.
- `xss-dom-html-sink`: `innerHTML`/`outerHTML` assignment,
`insertAdjacentHTML`, `document.write` (also on an opened window) and
jQuery `.html()` with a computed, unsanitized value.
- `xss-markdown-raw-html` (WARNING): `rehype-raw` without a later
`rehype-sanitize`.
- `xss-reflected-response-taint`: a request value in an Express
`send`/`write`/`end` string body (sent as text/html).
- **Path traversal (`typescript/path-traversal.yaml`).**
`path-traversal-fs-taint`: a request value (query, params, body, a
destructured body field, or `req.url`/`req.path`) reaching the path
argument of an fs read, write, stream or remove, or `res.sendFile` /
`res.download`; `path.basename` and filename sanitizers clear it.
- **Open redirect (`typescript/open-redirect.yaml`).**
`open-redirect-response-taint`: a request value as the target of
`res.redirect` or a `Location` header. `open-redirect-client-navigation`
(WARNING): an address-bar value (`URLSearchParams.get`, `location.search`,
`location.hash`) as the URL of `location.assign/replace/href` or
`window.open`.
- **SSRF (`typescript/ssrf.yaml`).** More outbound sinks: `http(s).request`,
the remaining axios methods, got, superagent, needle and undici.

Measured on eight checkouts (console, backstage, mlflow, keycloak, syndesis,
data-science-pipelines, argo-workflows, feast): 35 hits, including all three
confirmed TS/JS findings present in those checkouts (console `LogsPage.tsx`,
`SharedResourceWarning.js`, `backend/src/routes/serve.ts`). Precision is
unmeasured until the facts are judged (`/mine-ledger`). Not covered:
`javascript:` URLs bound to `href`, markdown-to-jsx and marked renderer
overrides, and sanitize-then-transform pipelines; these stay with review.
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import DOMPurify from "dompurify";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import $ from "jquery";

export const Entry = ({ response }: { response: string }) => (
// ruleid: traust-ts-xss-dangerously-set-inner-html
<div dangerouslySetInnerHTML={{ __html: interpolateLink(response) }} />
);

export const Clean = ({ response }: { response: string }) => (
// ok: traust-ts-xss-dangerously-set-inner-html
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(response) }} />
);

// ok: traust-ts-xss-dangerously-set-inner-html
export const Fixed = () => <div dangerouslySetInnerHTML={{ __html: "<b>static</b>" }} />;

export const Help = ({ t, i18nDescription, sanitizedContent }: any) => (
<>
{/* ok: traust-ts-xss-dangerously-set-inner-html */}
<p dangerouslySetInnerHTML={{ __html: t("shared:techPreviewPopoverHtml") }} />
{/* ok: traust-ts-xss-dangerously-set-inner-html */}
<p dangerouslySetInnerHTML={{ __html: i18nDescription }} />
{/* ok: traust-ts-xss-dangerously-set-inner-html */}
<p dangerouslySetInnerHTML={{ __html: sanitizedContent ?? "" }} />
</>
);

export const Warning = ({ t, resources }: any) => (
// ruleid: traust-ts-xss-dangerously-set-inner-html
<p dangerouslySetInnerHTML={{ __html: `${t("Warning:")} ${resources.join(", ")}` }} />
);

export function openRawTab(logs: string): void {
const rawWindow = window.open("about:blank");
// ruleid: traust-ts-xss-dom-html-sink
rawWindow?.document.write(`<pre>${logs}</pre>`);
// ok: traust-ts-xss-dom-html-sink
document.write("<p>loading</p>");
}

export async function loadConfig(response: Response): Promise<void> {
const script = await response.text();
const configScript = document.createElement("script");
// ruleid: traust-ts-xss-dom-html-sink
configScript.innerHTML = script;
const box = document.getElementById("box")!;
// ok: traust-ts-xss-dom-html-sink
box.innerHTML = "";
// ok: traust-ts-xss-dom-html-sink
box.innerHTML = DOMPurify.sanitize(script);
// ruleid: traust-ts-xss-dom-html-sink
box.insertAdjacentHTML("beforeend", script);
// ruleid: traust-ts-xss-dom-html-sink
$("#answers").html(marked.parse(script));
// ok: traust-ts-xss-dom-html-sink
box.textContent = script;
}

export const Docs = ({ text }: { text: string }) => (
// ruleid: traust-ts-xss-markdown-raw-html
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{text}</ReactMarkdown>
);

export const SafeDocs = ({ text }: { text: string }) => (
// ok: traust-ts-xss-markdown-raw-html
<ReactMarkdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>{text}</ReactMarkdown>
);

export function bundle(req: any, res: any): void {
const sha = req.params.sha;
// ruleid: traust-ts-xss-reflected-response-taint
res.status(404).send(`Bundle ${sha} not found`);
// ruleid: traust-ts-xss-reflected-response-taint
res.send("unknown filetype " + req.query.filetype);
// ok: traust-ts-xss-reflected-response-taint
res.status(404).send(`Bundle ${escapeHtml(sha)} not found`);
// ok: traust-ts-xss-reflected-response-taint
res.json({ error: `Bundle ${sha} not found` });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
rules:
- id: traust-ts-xss-dangerously-set-inner-html
languages: [typescript, javascript]
paths:
exclude:
- "*.test.*"
- "*.spec.*"
- "*/__tests__/*"
- "*/__mocks__/*"
- "*/mock-backend/*"
- "*/mocks/*"
- "*/test/*"
- "*/tests/*"
severity: ERROR
message: >-
A computed value is rendered as raw HTML through dangerouslySetInnerHTML
with no sanitizer — markup from log lines, WebSocket messages or API
data runs as script in the viewer's session. Render it as text, or pass
it through DOMPurify.sanitize first. CWE-79.
metadata:
cwe: ["CWE-79"]
category: cross-site-scripting
asvs: ["V5"]
confidence: MEDIUM
references:
- "CWE-79 DOM XSS — unsanitized dangerouslySetInnerHTML"
patterns:
- pattern: "{__html: $X}"
- pattern-not: "{__html: \"...\"}"
- pattern-not: "{__html: $SAN.sanitize(...)}"
- pattern-not: "{__html: sanitize(...)}"
- pattern-not: "{__html: sanitizeHtml(...)}"
- pattern-not: "{__html: DOMPurify.sanitize(...)}"
- pattern-not: "{__html: xss(...)}"
# translation strings and already-sanitized values: `t(...)`, `i18nX`, `props.i18nX(...)`,
# `sanitizedContent`
- metavariable-regex:
metavariable: $X
regex: (?is)^(?!\s*(?:[\w$]+\.)*(?:t|i18n\w*|sanitiz\w*|purif\w*)\b)

- id: traust-ts-xss-dom-html-sink
languages: [typescript, javascript]
paths:
exclude:
- "*.test.*"
- "*.spec.*"
- "*/__tests__/*"
- "*/__mocks__/*"
- "*/mock-backend/*"
- "*/mocks/*"
- "*/test/*"
- "*/tests/*"
severity: ERROR
message: >-
A computed value is written into the page as HTML (innerHTML,
outerHTML, insertAdjacentHTML, document.write or jQuery .html()) with
no sanitizer — markup in it executes as script. Use textContent, or
sanitize with DOMPurify first. CWE-79.
metadata:
cwe: ["CWE-79"]
category: cross-site-scripting
asvs: ["V5"]
confidence: MEDIUM
references:
- "CWE-79 DOM XSS — HTML sink fed a computed value"
patterns:
- pattern-either:
- pattern: $E.innerHTML = $X
- pattern: $E.outerHTML = $X
- pattern: $E.insertAdjacentHTML($POS, $X)
- pattern: document.write($X)
- pattern: document.writeln($X)
- pattern: $W.document.write($X)
- pattern: $J($SEL).html($X)
- pattern-not: $E.innerHTML = "..."
- pattern-not: $E.innerHTML = ""
- pattern-not: $E.outerHTML = "..."
- pattern-not: $E.insertAdjacentHTML($POS, "...")
- pattern-not: document.write("...")
- pattern-not: $W.document.write("...")
- pattern-not: $J($SEL).html("...")
- metavariable-pattern:
metavariable: $X
patterns:
- pattern-not: $SAN.sanitize(...)
- pattern-not: sanitize(...)
- pattern-not: sanitizeHtml(...)
- pattern-not: escapeHtml(...)

- id: traust-ts-xss-markdown-raw-html
languages: [typescript, javascript]
paths:
exclude:
- "*.test.*"
- "*.spec.*"
- "*/__tests__/*"
- "*/__mocks__/*"
- "*/mock-backend/*"
- "*/mocks/*"
- "*/test/*"
- "*/tests/*"
severity: WARNING
message: >-
Markdown is rendered with raw HTML enabled (rehype-raw) and no
rehype-sanitize step — HTML embedded in the Markdown source reaches the
page unfiltered. Add rehype-sanitize after rehype-raw, or drop raw HTML.
CWE-79.
metadata:
cwe: ["CWE-79"]
category: cross-site-scripting
asvs: ["V5"]
confidence: MEDIUM
references:
- "CWE-79 stored XSS — Markdown renderer passing raw HTML"
patterns:
- pattern: "[..., rehypeRaw, ...]"
- pattern-not: "[..., rehypeRaw, ..., rehypeSanitize, ...]"
- pattern-not: "[..., rehypeRaw, ..., [rehypeSanitize, ...], ...]"

- id: traust-ts-xss-reflected-response-taint
languages: [typescript, javascript]
paths:
exclude:
- "*.test.*"
- "*.spec.*"
- "*/__tests__/*"
- "*/__mocks__/*"
- "*/mock-backend/*"
- "*/mocks/*"
- "*/test/*"
- "*/tests/*"
severity: ERROR
message: >-
A request value is echoed into a response body that Express sends as
text/html — markup in the path, query or body runs in the caller's
browser (reflected XSS). Return JSON, set a text/plain content type, or
HTML-escape the value. CWE-79.
metadata:
cwe: ["CWE-79"]
category: cross-site-scripting
asvs: ["V5"]
confidence: MEDIUM
references:
- "CWE-79 reflected XSS — request value in an HTML response body"
mode: taint
pattern-sources:
- pattern-either:
- pattern: $REQ.query.$P
- pattern: $REQ.params.$P
- pattern: $REQ.body.$P
- pattern: $REQ.query[$K]
- pattern: $REQ.params[$K]
pattern-sanitizers:
- pattern-either:
- pattern: escapeHtml(...)
- pattern: $X.escape(...)
- pattern: encodeURIComponent(...)
- pattern: JSON.stringify(...)
pattern-sinks:
- patterns:
- pattern-either:
- pattern: $RES.send(`...`)
- pattern: $RES.send("..." + $X)
- pattern: $RES.send($X + "...")
- pattern: $RES.status($C).send(`...`)
- pattern: $RES.status($C).send("..." + $X)
- pattern: $RES.write(`...`)
- pattern: $RES.end(`...`)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export function callback(req: any, res: any): void {
// ruleid: traust-ts-open-redirect-response-taint
res.redirect(req.query.next);
// ruleid: traust-ts-open-redirect-response-taint
res.redirect(302, req.query.returnTo);
// ok: traust-ts-open-redirect-response-taint
res.redirect("/login");
// ruleid: traust-ts-open-redirect-response-taint
res.setHeader("Location", req.query.next);
}

export function afterLogin(): void {
const params = new URLSearchParams(window.location.search);
const target = params.get("redirect");
// ruleid: traust-ts-open-redirect-client-navigation
window.location.assign(target);
// ok: traust-ts-open-redirect-client-navigation
window.location.assign("/home");
// ruleid: traust-ts-open-redirect-client-navigation
window.open(window.location.hash.slice(1), "_blank");
// ok: traust-ts-open-redirect-client-navigation
window.open("/graph", params.get("target") || "_top");
}
Loading