Skip to content

Commit 9bfdb3e

Browse files
authored
Add HTML decoder for secret detection in HTML-formatted sources (#4840)
* Add HTML decoder for secret detection in HTML-formatted sources Sources like MS Teams and Confluence emit HTML rather than plain text, causing secrets split across tags or embedded in attributes to be missed. This adds an HTML decoder to the pipeline that extracts text nodes, high-signal attribute values, script/style/comment content, and code blocks. It handles syntax-highlight boundary detection, zero-width character stripping, and double-encoded HTML entity decoding. Made-with: Cursor * Fix dead code and plus-sign corruption in HTML decoder - Remove unreachable "xlink:href" map entry: the html parser splits namespace-prefixed attributes into separate Namespace/Key fields, so attr.Key is "href" (already in the map), never "xlink:href". - Switch url.QueryUnescape to url.PathUnescape: QueryUnescape converts '+' to space per form-encoding spec, corrupting secrets that contain literal '+' characters (e.g. base64 values, API keys). Made-with: Cursor * updated comment around syntaxHighlightPrefixes to guide future additions * removed Enabled func from HTML struct to follow normal flag conventions * Fix script/style boundary, redundant br check, and raw-text entity corruption - Add script/style to blockElements so they get newline boundaries instead of concatenating with adjacent inline text. - Remove redundant `|| n.Data == "br"` since br is already in blockElements. - Move residual entity decoding into walkNode per text node, skipping it for script/style raw-text content where the HTML parser does not decode entities. Made-with: Cursor
1 parent bff3d26 commit 9bfdb3e

6 files changed

Lines changed: 731 additions & 0 deletions

File tree

pkg/decoders/decoders.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ func DefaultDecoders() []Decoder {
1212
&Base64{},
1313
&UTF16{},
1414
&EscapedUnicode{},
15+
&HTML{},
1516
}
1617
}
1718

pkg/decoders/html.go

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package decoders
2+
3+
import (
4+
"bytes"
5+
"net/url"
6+
"regexp"
7+
"strings"
8+
9+
"golang.org/x/net/html"
10+
11+
"github.com/trufflesecurity/trufflehog/v3/pkg/feature"
12+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
13+
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
14+
)
15+
16+
// HTML is a decoder that extracts textual content from HTML documents.
17+
// It produces a normalized view containing visible text, attribute values,
18+
// script/style content, and HTML comments with entities and URL-encoding decoded.
19+
// Gated at runtime by feature.HTMLDecoderEnabled.
20+
type HTML struct{}
21+
22+
func (d *HTML) Type() detectorspb.DecoderType {
23+
return detectorspb.DecoderType_HTML
24+
}
25+
26+
var htmlTagPattern = regexp.MustCompile(`<[a-zA-Z][a-zA-Z0-9]*[\s>/]`)
27+
28+
// highSignalAttrs are attribute names whose values are extracted into the
29+
// decoded output because they commonly contain URLs, tokens, or other secrets.
30+
var highSignalAttrs = map[string]bool{
31+
"href": true,
32+
"src": true,
33+
"action": true,
34+
"value": true,
35+
"content": true,
36+
"alt": true,
37+
"title": true,
38+
}
39+
40+
// syntaxHighlightPrefixes lists CSS class prefixes used by syntax highlighting
41+
// libraries. Elements with these classes mark logical line boundaries in code
42+
// blocks where the platform (e.g. Teams) strips actual newlines.
43+
var syntaxHighlightPrefixes = []string{"hljs-"}
44+
45+
// residualEntityReplacer decodes common HTML entities that survive double-encoding.
46+
// When content is entity-encoded twice (e.g. &amp;amp;), the parser's first pass
47+
// leaves residual entity sequences that this replacer cleans up.
48+
var residualEntityReplacer = strings.NewReplacer(
49+
"&amp;", "&",
50+
"&lt;", "<",
51+
"&gt;", ">",
52+
"&quot;", `"`,
53+
"&#39;", "'",
54+
"&apos;", "'",
55+
)
56+
57+
// invisibleReplacer strips zero-width and invisible Unicode codepoints that
58+
// rich text editors may insert between characters, breaking detector regexes.
59+
var invisibleReplacer = strings.NewReplacer(
60+
"\u200B", "", // zero-width space
61+
"\u200C", "", // zero-width non-joiner
62+
"\u200D", "", // zero-width joiner
63+
"\uFEFF", "", // byte order mark / zero-width no-break space
64+
"\u00AD", "", // soft hyphen
65+
"\u2060", "", // word joiner
66+
"\u200E", "", // left-to-right mark
67+
"\u200F", "", // right-to-left mark
68+
)
69+
70+
// blockElements insert newline boundaries when encountered during extraction.
71+
var blockElements = map[string]bool{
72+
"p": true, "div": true, "br": true, "hr": true,
73+
"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true,
74+
"li": true, "ol": true, "ul": true,
75+
"tr": true, "td": true, "th": true, "table": true, "thead": true, "tbody": true, "tfoot": true,
76+
"blockquote": true, "section": true, "article": true, "header": true, "footer": true,
77+
"pre": true, "address": true, "figcaption": true, "figure": true,
78+
"details": true, "summary": true, "main": true, "nav": true, "aside": true,
79+
"form": true, "fieldset": true, "legend": true,
80+
"dd": true, "dt": true, "dl": true,
81+
"script": true, "style": true,
82+
}
83+
84+
// rawTextElements are elements whose content the HTML parser treats as raw
85+
// text (entities are NOT decoded). Residual entity decoding must be skipped
86+
// for text nodes inside these elements to avoid corrupting literal sequences
87+
// like &amp; in JavaScript.
88+
var rawTextElements = map[string]bool{
89+
"script": true,
90+
"style": true,
91+
}
92+
93+
func (d *HTML) FromChunk(chunk *sources.Chunk) *DecodableChunk {
94+
if !feature.HTMLDecoderEnabled.Load() {
95+
return nil
96+
}
97+
if chunk == nil || len(chunk.Data) == 0 {
98+
return nil
99+
}
100+
101+
if !looksLikeHTML(chunk.Data) {
102+
return nil
103+
}
104+
105+
extracted := extractHTML(chunk.Data)
106+
if len(extracted) == 0 {
107+
return nil
108+
}
109+
110+
if bytes.Equal(chunk.Data, extracted) {
111+
return nil
112+
}
113+
114+
chunk.Data = extracted
115+
return &DecodableChunk{Chunk: chunk, DecoderType: d.Type()}
116+
}
117+
118+
func looksLikeHTML(data []byte) bool {
119+
return htmlTagPattern.Match(data)
120+
}
121+
122+
func extractHTML(data []byte) []byte {
123+
doc, err := html.Parse(bytes.NewReader(data))
124+
if err != nil {
125+
return nil
126+
}
127+
128+
var buf bytes.Buffer
129+
buf.Grow(len(data))
130+
131+
walkNode(&buf, doc, false)
132+
133+
result := stripInvisible(buf.Bytes())
134+
return normalizeWhitespace(result)
135+
}
136+
137+
func walkNode(buf *bytes.Buffer, n *html.Node, inRawText bool) {
138+
switch n.Type {
139+
case html.TextNode:
140+
text := n.Data
141+
if text != "" {
142+
if !inRawText {
143+
text = residualEntityReplacer.Replace(text)
144+
}
145+
buf.WriteString(text)
146+
}
147+
148+
case html.CommentNode:
149+
if content := strings.TrimSpace(n.Data); content != "" {
150+
ensureNewline(buf)
151+
buf.WriteString(content)
152+
ensureNewline(buf)
153+
}
154+
155+
case html.ElementNode:
156+
isBlock := blockElements[n.Data]
157+
158+
if isBlock {
159+
ensureNewline(buf)
160+
} else if hasSyntaxHighlightClass(n) {
161+
ensureNewline(buf)
162+
}
163+
164+
emitAttributes(buf, n)
165+
166+
childRaw := inRawText || rawTextElements[n.Data]
167+
for c := n.FirstChild; c != nil; c = c.NextSibling {
168+
walkNode(buf, c, childRaw)
169+
}
170+
171+
if isBlock {
172+
ensureNewline(buf)
173+
}
174+
175+
default:
176+
for c := n.FirstChild; c != nil; c = c.NextSibling {
177+
walkNode(buf, c, inRawText)
178+
}
179+
}
180+
}
181+
182+
func hasSyntaxHighlightClass(n *html.Node) bool {
183+
for _, attr := range n.Attr {
184+
if attr.Key != "class" {
185+
continue
186+
}
187+
for _, cls := range strings.Fields(attr.Val) {
188+
for _, prefix := range syntaxHighlightPrefixes {
189+
if strings.HasPrefix(cls, prefix) {
190+
return true
191+
}
192+
}
193+
}
194+
}
195+
return false
196+
}
197+
198+
func emitAttributes(buf *bytes.Buffer, n *html.Node) {
199+
for _, attr := range n.Attr {
200+
isDataAttr := strings.HasPrefix(attr.Key, "data-")
201+
if !highSignalAttrs[attr.Key] && !isDataAttr {
202+
continue
203+
}
204+
val := strings.TrimSpace(attr.Val)
205+
if val == "" || val == "#" {
206+
continue
207+
}
208+
decoded, err := url.PathUnescape(val)
209+
if err == nil && decoded != val {
210+
val = decoded
211+
}
212+
ensureNewline(buf)
213+
buf.WriteString(val)
214+
ensureNewline(buf)
215+
}
216+
}
217+
218+
func ensureNewline(buf *bytes.Buffer) {
219+
if buf.Len() == 0 {
220+
return
221+
}
222+
if buf.Bytes()[buf.Len()-1] != '\n' {
223+
buf.WriteByte('\n')
224+
}
225+
}
226+
227+
func stripInvisible(data []byte) []byte {
228+
return []byte(invisibleReplacer.Replace(string(data)))
229+
}
230+
231+
// normalizeWhitespace collapses runs of blank lines and trims leading/trailing whitespace.
232+
func normalizeWhitespace(data []byte) []byte {
233+
lines := bytes.Split(data, []byte("\n"))
234+
var result [][]byte
235+
prevBlank := true
236+
for _, line := range lines {
237+
trimmed := bytes.TrimSpace(line)
238+
if len(trimmed) == 0 {
239+
if !prevBlank {
240+
prevBlank = true
241+
}
242+
continue
243+
}
244+
if prevBlank && len(result) > 0 {
245+
result = append(result, []byte(""))
246+
}
247+
result = append(result, trimmed)
248+
prevBlank = false
249+
}
250+
return bytes.Join(result, []byte("\n"))
251+
}

0 commit comments

Comments
 (0)