| date | 2026-04-05 |
|---|---|
| scope | Adversarial markdown input causing unintended side effects on the host machine |
| method | 5 parallel blind-review agents, each targeting a different attack surface |
| title | Adversarial Markdown Input |
| summary | 10 findings (1 high, 1 medium-high, 8 medium) covering OOM, YAML bomb, ANSI injection, symlinks, path traversal, content injection, CUE injection, schema path, TOCTOU, and include size. Cross-linter comparison with top 5 tools. |
| # | Finding | Severity | Attack Vector | File(s) |
|---|---|---|---|---|
| 1 | No file-size limit — OOM via large input | High | Any .md arg or stdin |
internal/engine/runner.go:52, internal/fix/fix.go:84, cmd/mdsmith/main.go:577 |
| 2 | YAML billion-laughs via anchor expansion | Medium-High | Front matter in any scanned .md |
internal/config/load.go:22, internal/archetype/gensection/parse.go:189, internal/rules/catalog/rule.go:415 |
| 3 | ANSI escape injection in terminal output | Medium | Heading/source content in any .md |
internal/output/text.go:23,78 |
| 4 | Symlinks followed by default (read+write) | Medium | Symlink in repo, fix overwrites target |
internal/lint/files.go:190, internal/fix/fix.go:84-122 |
| 5 | Path traversal in cross-file-reference-integrity | Medium | [link](../../../etc/secret.md#h) in .md |
internal/rules/crossfilereferenceintegrity/rule.go:258 |
| 6 | Catalog front-matter Markdown injection | Medium | Front matter values with ](...) |
internal/fieldinterp/fieldinterp.go, internal/rules/catalog/rule.go |
| 7 | CUE expression injection via schema values | Medium | Schema .md front matter strings |
internal/rules/requiredstructure/rule.go:285-290 |
| 8 | Unvalidated schema path (arbitrary read) | Medium | .mdsmith.yml schema: setting |
internal/rules/requiredstructure/rule.go:82 |
| 9 | Non-atomic write in fix mode (TOCTOU) | Low-Medium | Symlink swap between read and write | internal/fix/fix.go:84-122 |
| 10 | No size limit on included files | Medium | <?include file: "huge.md"?> |
internal/rules/include/rule.go:194 |
Locations:
internal/engine/runner.go:52—os.ReadFile(path)with no size checkcmd/mdsmith/main.go:577—io.ReadAll(os.Stdin)with no limitinternal/fix/fix.go:84—os.ReadFile(path)in fix path
The entire file is loaded into memory before any rule fires. The max-file-length rule (MDS022) only emits a diagnostic after loading — it does not prevent the load. lint.NewFileFromSource then calls bytes.Split, duplicating the allocation.
Adversarial file: A multi-GB .md file (or one piped via stdin) triggers OOM. In CI, this kills the linting job or exhausts container memory.
Recommendation: Add an io.LimitReader / os.Stat size guard before os.ReadFile. A sensible default (e.g., 10 MB) with a --max-input-size override would suffice.
Library: gopkg.in/yaml.v3 v3.0.1 — no alias-expansion depth or size limit.
Locations: Every yaml.Unmarshal call:
internal/config/load.go:22— config fileinternal/archetype/gensection/parse.go:189— directive YAML bodyinternal/rules/catalog/rule.go:415— per-file front matterinternal/rules/requiredstructure/rule.go:220,233— schema front mattercmd/mdsmith/main.go:352—querysubcommand
Adversarial file: Any .md with exponentially-nested YAML anchors in its front matter:
---
a: &a ["x","x","x","x","x","x","x","x","x","x"]
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a,*a]
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b,*b]
d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c,*c]
---This expands to 10^4 = 10,000 elements (4 levels). With 8 levels: 10^8 = 100 million strings. OOM or CPU exhaustion.
Recommendation: Use yaml.NewDecoder with a size-limited reader, or pre-check front-matter byte length before unmarshalling.
Location: internal/output/text.go:23-26, 78-80
Diagnostic messages and source-line snippets embed raw user-controlled content via %s with no sanitization:
fmt.Fprintf(w, "...%s\n", d.Message) // line 23-26
fmt.Fprintf(w, format, ..., line) // line 78 (source snippet)d.Message often includes verbatim heading text. line is a raw source line from the file.
Adversarial file:
## Title\033[2J\033]0;pwned\007Escape sequences pass through to the terminal: screen clearing (\033[2J), window-title hijacking (\033]0;...), OSC hyperlink injection. Even with --no-color, source snippets are still unescaped.
Recommendation: Strip or replace \x1b bytes in all user-controlled strings before writing to the terminal.
Locations (after plan 84):
internal/lint/files.go—resolveArg,resolveGlob, andwalkDirLstat each path entry and skip symbolic links unlessFollowSymlinksopts in.hasSymlinkAncestoralso rejects any path whose relative ancestors include a symlinked directory, solinked/dirty.mdandlinked/*.mdcannot reach external targets.internal/discovery/discovery.go— the discovery walker applies the same Lstat-based skip during directory traversal.internal/fix/fix.go— atomic write viaos.Rename(tmp, path)replaces the symlink entry itself rather than following it to the target (plan 83 write-side protection).
Original finding: symlink following was the default; the --no-follow-symlinks flag and config key were opt-in.
Status (plan 84, resolved): the default is inverted. Symlinks are skipped by default across directory walks, glob expansion, and explicit non-glob path arguments. Symlinked directories are always skipped — including when a path or glob traverses through one — regardless of FollowSymlinks. Users opt in (for file symlinks) with --follow-symlinks or follow-symlinks: true. The old --no-follow-symlinks flag has been removed; the legacy no-follow-symlinks: config key still parses and emits a deprecation warning.
Adversarial file:
# Attacker places in repo:
ln -s /etc/cron.d/jobs evil.md
# CI runs: mdsmith fix .
# Result: /etc/cron.d/jobs is overwritten with "fixed" markdown contentRecommendation: Default to O_NOFOLLOW semantics for write operations, or at minimum os.Lstat before write to detect symlinks.
Location: internal/rules/crossfilereferenceintegrity/rule.go:258-269
func resolveTargetOSPath(sourcePath, linkPath string) (string, bool) {
return filepath.Clean(filepath.Join(filepath.Dir(sourcePath), linkPath)), true
}No project-root boundary check. A link with ../../../ resolves to an absolute path and os.ReadFile is called on it.
Adversarial file:
[notes](../../../../home/user/private-notes.md#secret-heading)Causes os.ReadFile("/home/user/private-notes.md"). Content is parsed for heading anchors — existence of specific headings leaks as lint diagnostics.
By default only .md/.markdown targets are followed; with strict: true all targets get os.Stat.
Recommendation: Compare resolved path against a project-root boundary and reject traversals above it.
Locations:
internal/fieldinterp/fieldinterp.go—Interpolate()inserts values as plain stringsinternal/rules/catalog/rule.go— row template rendering
Front-matter values are interpolated into Markdown templates without escaping.
Adversarial file: A .md with crafted front matter:
---
summary: "](evil.com) [click me"
---Template - [{summary}]({filename}) produces:
- [](evil.com) [click me](path.md)This injects arbitrary links into the generated catalog section. Newlines in values can escape the row and inject additional Markdown structure.
Recommendation: Escape [, ], (, ), and newlines in interpolated field values.
Location: internal/rules/requiredstructure/rule.go:285-290
Schema YAML string values are embedded verbatim into CUE source that is compiled and evaluated:
case string:
expr := strings.TrimSpace(x)
return expr, nil // raw CUE expression — no sanitizationThis flows to ctx.CompileString(schema). An attacker controlling the schema file can inject closing braces to escape close({...}) and inject arbitrary CUE constructs — potentially bypassing all validation or causing DoS via expensive CUE evaluation.
Recommendation: Quote string values as CUE string literals rather than embedding them as raw expressions, or validate that values parse as a single CUE expression.
Location: internal/rules/requiredstructure/rule.go:82
schData, err := os.ReadFile(r.Schema)The schema setting from .mdsmith.yml is passed directly to os.ReadFile — absolute paths and ../ traversals are accepted. Content is parsed as Markdown; heading text appears in diagnostic messages.
Adversarial config: schema: /etc/passwd — mdsmith reads the file and leaks partial content in error messages.
Recommendation: Reject absolute paths and .. segments in the schema setting, or resolve relative to config-file directory with boundary check.
Location: internal/fix/fix.go:84-122
The fix pipeline: ReadFile → process → WriteFile to the same path. No locking, no atomic rename. A symlink can be swapped between read and write.
Recommendation: Write to a temp file in the same directory, then os.Rename into place.
Location: internal/rules/include/rule.go:194
fs.ReadFile(readFS, readPath) reads the full included file with no size guard. The include depth limit is 10, so 10 large files can be loaded.
Recommendation: Apply the same size limit as recommended for primary input files.
| Area | Status |
|---|---|
| Command injection | No os/exec calls use directive parameters. All exec uses hardcoded subcommands. |
| Go template injection | No text/template or html/template usage. Custom {field} interpolation only. |
| ReDoS | Go's regexp uses RE2 (linear time). Not exploitable. |
| Include path traversal | Absolute paths blocked, .. segments rejected, os.DirFS boundary enforced. |
| Catalog glob traversal | Absolute paths and .. rejected; doublestar.Glob operates within os.DirFS. |
| Circular includes | Visited-set cycle detection + maxIncludeDepth = 10. |
| Infinite loops | All loops terminate on finite input; PI parser returns Close at EOF. |
| Environment variable expansion | Not present anywhere in config or directives. |
| Supply chain | No known-vulnerable runtime dependencies. Large indirect dep set is from dev-only tools. |
The highest-risk scenario is a CI pipeline running mdsmith check . or mdsmith fix . on untrusted pull requests. An attacker contributing a PR can craft .md files that:
- OOM the CI runner (findings 1, 2, 10)
- Read files outside the repo via cross-file-reference links (finding 5)
- Inject terminal escapes into CI logs (finding 3)
- Inject malicious links into catalog-generated sections if
fixis run (finding 6) - Overwrite files via symlinks if
fixis run (finding 4)
The attacker does NOT need to control .mdsmith.yml for findings 1-5 — only the .md files in the PR.
Each proposed mitigation was blind-reviewed by an independent agent for correctness, completeness, hidden tradeoffs, and whether a better alternative exists. Below are the final recommendations.
Original proposal: os.Stat size guard before os.ReadFile at 3 call sites.
Problems identified:
os.Statintroduces TOCTOU — file can grow between stat and read.- Only 3 of ~15 production
os.ReadFile/io.ReadAll/fs.ReadFilesites were covered. Missed sites includemetrics/rank.go,mergedriver.go,crossfilereferenceintegrity/rule.go,requiredstructure/rule.go, andcatalog/rule.go. - 10 MB default is generous; 1–2 MB is equally safe for Markdown.
Revised recommendation: Create a shared readFileLimited(path string, max int64) ([]byte, error) helper using os.Open + io.LimitReader(f, max+1) + post-read length assertion. Apply uniformly across all ~15
production call sites. Default limit 2 MB, overridable via
--max-input-size. ~30 lines of new code for complete coverage.
Verdict: Fix is correct in concept but under-scoped. The shared helper approach is strictly better.
Original proposal: Cap front-matter byte length at 64 KB before
yaml.Unmarshal.
Problems identified:
- Byte-length limiting does not prevent the attack. A 1 KB YAML with 8 levels of nested aliases expands to 10^8 strings. The attack uses small input that expands exponentially — capping input bytes misses the point entirely.
gopkg.in/yaml.v3has no alias-depth or expansion-size controls.- Only 2 of 13
yaml.Unmarshalcall sites were covered.
Revised recommendation (pick one):
- Pre-scan for anchors/aliases (simplest): Before any
yaml.Unmarshalon user-supplied content, reject input containing YAML anchor (&) or alias (*) syntax. Legitimate Markdown front matter virtually never uses YAML anchors. One-line check, zero false positives for this tool's use case. - Switch to
github.com/goccy/go-yaml: Providesyaml.WithMaxAliasesNum()andyaml.WithMaxLiteralStringSize(), eliminating the problem structurally.
Apply to all 13 yaml.Unmarshal sites that process user-supplied .md
content, not just 2.
Verdict: Original fix is ineffective. Pre-scan for &/* is the
minimum viable fix.
Original proposal: Replace \x1b with \u241b in
sanitizeTerminal().
Problems identified:
\x1bis not the only injection vector.\x9b(C1 CSI) is a single-byte CSI recognized by most terminals. The full C1 range (0x80–0x9F) includes\x9d(OSC),\x9c(ST). Also\x07(BEL) and\x08(backspace) can produce misleading output.\u241bmay display as?on non-Unicode terminals.- Output-layer sanitization (in
text.go) is the correct layer — it preserves raw data for JSON consumers. JSONFormatteris safe (Go'sencoding/jsonescapes control chars).
Revised recommendation: Use strings.Map to strip all control
bytes: 0x00–0x08, 0x0B–0x0C, 0x0E–0x1F, 0x7F, and 0x80–0x9F. Preserve
only \t (0x09), \n (0x0A), \r (0x0D). Apply in TextFormatter
only (not JSONFormatter). No external dependency needed.
func sanitizeTerminal(s string) string {
return strings.Map(func(r rune) rune {
if r == '\t' || r == '\n' || r == '\r' {
return r
}
if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) {
return -1 // strip
}
return r
}, s)
}Verdict: Right approach, incomplete character set. The
strings.Map version is more resilient with similar complexity.
Original proposal: (a) Invert default to reject symlinks in
walkDir, (b) os.Lstat before write in fix.go.
Problems identified:
os.Lstatbefore write has its own TOCTOU — symlink created between Lstat and WriteFile. Atomic rename (M9) is the proper write-side fix.- Default-deny should apply to
checktoo, not justfix— reading/etc/passwdvia symlink in CI is also a risk. - 5 other read paths follow symlinks and are unguarded: include
directive, catalog glob, cross-file-reference-integrity,
resolveGlob, all viaos.DirFSwhich follows symlinks. filepath.WalkDir(Go 1.16+) withd.Type()is more efficient thanfilepath.Walk+ extraLstat.
Revised recommendation:
- Default-deny symlinks in
walkDirfor bothcheckandfix(breaking change — document in release notes). Add--follow-symlinksopt-in flag. - Drop the
Lstat-before-write approach entirely; rely on atomic rename from M9 instead (os.Renamereplaces the symlink itself, not the target). - Consider migrating to
filepath.WalkDirfor a cleaner symlink check without extra syscalls.
Verdict: Write-side fix should be atomic rename (M9), not Lstat. Read-side default-deny is the correct primary fix.
Original proposal: strings.HasPrefix(absResolved, absRoot+sep) in
resolveTargetOSPath.
Problems identified:
strings.HasPrefixwithabsRoot+sepis fragile: trailing slashes on root (WindowsC:\), case sensitivity (macOS HFS+), and symlink resolution of root itself all cause bypasses.lint.FilecarriesFS/RootFS(fs.FSvalues) but not aRootDirstring — plumbing is needed.- Boundary check must happen before
os.Stat, not just beforeos.ReadFile, to prevent existence-leak instrict: truemode.
Revised recommendation:
- Add
RootDir stringfield tolint.File, populated inrunner.RunalongsideRootFS. - Use
filepath.Rel(absRoot, absResolved)+ check for leading..— idiomatic Go, handles all separator edge cases. - Move boundary enforcement into
resolveTargetFile(beforeos.Stat) not justresolveTargetOSPath. - When
RootDiris empty (no config), skip the OS-path branch and fall through to thefs.FS-based path (already root-bounded).
Verdict: filepath.Rel is strictly better than HasPrefix. ~10
lines of correct code.
Original proposal: escapeMarkdownInline() in
fieldinterp.Interpolate escaping [, ], (, ).
Problems identified:
- Wrong layer.
fieldinterp.Interpolateis a general-purpose utility used by include, schema, and other non-Markdown contexts. Escaping there would corrupt those paths. - Breaks existing usage. The project's own
CLAUDE.mdcatalog uses{summary}values containing Markdown-formatted text with brackets. Silent escaping would produce\[Build commands\]— visibly broken. - Incomplete character set: misses
`,<,>,!,*,_. - Newline → space is wrong for YAML block scalars with meaningful structure.
Revised recommendation: Emit a lint diagnostic (not silent
escaping) when a front-matter value interpolated into a catalog row
template contains suspicious characters: embedded newlines, or unbalanced
[/] patterns that would break link syntax. Apply in the catalog rule's
renderTemplate path specifically, not in fieldinterp.Interpolate.
This surfaces the problem at authoring time without corrupting legitimate
content.
Verdict: Silent escaping is harmful. A targeted lint warning is the proportionate fix.
Original proposal: Pre-validate each string by compiling in isolation
with cuecontext.New().CompileString(expr).
Problems identified:
- Pre-compile doesn't prevent injection.
string | _is valid CUE in isolation and in the wrapper — but it weakens the constraint to accept any type. Isolation compilation cannot detect context-dependent injection. - Strings are intentionally CUE expressions (constraints like
=~ "pattern",>= 0). Quoting them would break all legitimate use. - CUE has no I/O, no exec, no network. The blast radius is limited to schema bypass or DoS — no RCE possible.
- Threat requires
.mdsmith.ymlwrite access — whoever controls the config already controls linting behavior.
Revised recommendation: The CUE API (CompileString, Unify,
Validate) is not context-aware, so context.WithTimeout cannot
interrupt long-running evaluation. Use GOMEMLIMIT at process level
to cap memory. Accept the schema-bypass risk as inherent to the
design — it's within the trust boundary of config-file authors.
Verdict: Low priority. The proposed fix is ineffective and the threat requires high privilege. Memory limiting is the realistic bound.
Original proposal: Reject .. and absolute paths in
ApplySettings, resolve relative to config dir.
Problems identified:
- Symlinks bypass
..check.schemas/evil→/etc/passwdpasses all string checks. - Config dir is not available at
ApplySettingstime — would require an interface signature change across all rule implementors. - Switching from CWD-relative to config-dir-relative resolution is a breaking change.
Revised recommendation: Read the schema via f.RootFS (the
project-root-scoped fs.FS already on lint.File) instead of
os.ReadFile(r.Schema). This:
- Handles symlinks structurally (
os.DirFSboundary) - Requires no interface changes
- Is consistent with how the
includedirective reads files - Avoids the CWD vs. config-dir ambiguity
Verdict: fs.FS-scoped read is architecturally cleaner and more
complete than string-based path validation.
Original proposal: Temp-file-then-rename in fix.go.
Review verdict: Sound as proposed. Key validations:
os.Renameis atomic on Linux/macOS within the same filesystem. Same-directory temp placement ensures this.os.Rename(tmp, path)replaces the symlink itself (not the target), correctly closing the TOCTOU symlink-swap vector on write.- Minor gaps are acceptable:
EXDEVcross-device fails cleanly (error, not corruption); ACL/xattr loss is irrelevant for Markdown files. - Merge driver's writes to
pathname(lines 140, 157) are a worthwhile follow-on target.
Verdict: Implement as proposed. This is the correct write-side fix that also subsumes M4's write protection.
Original proposal: fs.ReadFile + post-read size check, or
fs.Open + io.LimitReader.
Problems identified:
- For
os.DirFS-backed reads,fs.ReadFilecallsos.ReadFileinternally which pre-allocates based onstat.Size()— the entire file is already in memory before the check runs.io.LimitReaderprovides no benefit over a post-read check for this code path. - Off-by-one: must read
limit+1bytes to distinguish "exact limit" from "truncated". catalog/rule.gohas parallel unguardedfs.ReadFilecalls (readFrontMatterat :395,scanIncludesForTargetat :468) — not covered by this fix.- No cumulative limit across the include chain (10 files × 2 MB = 20 MB).
Revised recommendation: Use the shared readFileLimited helper from
M1 (post-read len(data) > limit check). Apply to:
include/rule.go:194catalog/rule.go:395and:468
The simpler post-read check is equally effective as io.LimitReader for
os.DirFS and avoids unnecessary complexity. Consider reducing
maxIncludeDepth from 10 to 5 (real nesting never exceeds 3–4).
Verdict: Post-read check via shared helper is sufficient. Extend coverage to catalog reads.
| Priority | Mitigation | Complexity | Impact |
|---|---|---|---|
| 1 | M1 — readFileLimited helper across all sites |
Low (~30 LOC) | Eliminates OOM from large files |
| 2 | M2 — Pre-scan for YAML &/* before unmarshal |
Low (~10 LOC per site) | Eliminates billion-laughs |
| 3 | M9 — Atomic write in fix mode | Low (~20 LOC) | Eliminates TOCTOU + partial writes |
| 4 | M3 — strings.Map control-char stripping |
Low (~15 LOC) | Eliminates terminal injection |
| 5 | M4 — Default-deny symlinks in walkDir | Medium (breaking) | Eliminates symlink read+write attacks |
| 6 | M5 — filepath.Rel boundary in cross-file-ref |
Low (~10 LOC + plumbing) | Eliminates out-of-project reads |
| 7 | M8 — Schema read via f.RootFS |
Low (~5 LOC) | Eliminates arbitrary file read |
| 8 | M10 — Size limit on include + catalog reads | Low (reuse M1 helper) | Bounds include-chain memory |
| 9 | M6 — Lint warning on suspicious catalog values | Low (~20 LOC) | Surfaces injection at author time |
| 10 | M7 — CUE evaluation timeout | Low (~5 LOC) | Bounds DoS from complex expressions |
Comparison of how Prettier (51K stars), markdownlint (6K), Vale (5.3K), remark-lint (1K + remark 8.8K), and textlint (3.1K) handle the same 10 security concerns found in mdsmith.
- Vuln = Same vulnerability exists, no mitigation
- Mitigated = Vulnerability exists but is actively mitigated
- By design = Intentional behavior, documented as expected
- N/A = Feature doesn't exist, so attack surface absent
- Fixed = Was vulnerable, now patched
| # | Exploit | mdsmith | Prettier | markdownlint | Vale | remark-lint | textlint |
|---|---|---|---|---|---|---|---|
| 1 | OOM (no file-size limit) | Vuln | Vuln | Vuln | Vuln | Vuln (documented advisory) | Vuln |
| 2 | YAML billion-laughs | Vuln | Mitigated (eemeli/yaml) |
N/A (no YAML parse) | Mitigated (yaml.v2 v2.4.0) |
Mitigated (eemeli/yaml maxAliasCount:100) |
Vuln (js-yaml v4, no limit) |
| 3 | ANSI escape injection | Vuln | Vuln | Vuln | Vuln | Vuln | Vuln |
| 4 | Symlinks followed | Vuln | Fixed (v3.0) | Vuln | Vuln (explicit) | Vuln (partial) | Mitigated (glob default) |
| 5 | Path traversal (link check) | Vuln | N/A | Vuln (3rd-party rule) | N/A | Vuln (documented risk) | Vuln (3rd-party rule) |
| 6 | Content injection via front matter | Vuln | N/A | N/A | N/A | N/A | N/A |
| 7 | Config expression/code injection | Vuln (CUE) | By design (JS plugins) | By design (.cjs config) |
By design (Tengo scripts) | By design (.remarkrc.js) |
By design (--rulesdir) |
| 8 | Unvalidated config paths | Vuln | Low risk | Vuln (extends) |
Vuln (packages/zip) | Vuln (config search) | Vuln (--config) |
| 9 | Non-atomic writes | Vuln | Vuln | Vuln | N/A (read-only) | Vuln | Vuln |
| 10 | No include size limit | Vuln | N/A | N/A | N/A | Vuln (3rd-party plugins) | N/A |
1. OOM from large files is an industry-wide gap.
Every tool examined loads entire files into memory with no size guard.
remark's docs advise callers to "cap input at 500 KB" but don't enforce
it. mdsmith implementing M1 (readFileLimited) would make it the first
in this group to have a built-in defense.
2. YAML billion-laughs is a library choice.
eemeli/yaml(used by Prettier, remark) defaults tomaxAliasCount: 100— effective mitigation with zero application code.gopkg.in/yaml.v2v2.4.0 (used by Vale) back-ported alias-depth fixes — also effective.gopkg.in/yaml.v3(used by mdsmith) andjs-yamlv4 (used by textlint) have no alias limits — both are vulnerable.- markdownlint avoids the issue entirely by not parsing YAML.
- Recommendation for mdsmith: M2's pre-scan for
&/*tokens remains the simplest fix. Alternatively, switching togoccy/go-yaml(which hasMaxAliasesNum) would match theeemeli/yamlapproach.
3. ANSI escape injection is universal and unmitigated.
All 6 tools (including mdsmith) emit user-controlled content to the
terminal without stripping control characters. No tool has shipped a
fix. mdsmith implementing M3 (strings.Map sanitizer) would be
first-in-class.
4. Symlinks: only Prettier has fixed this.
Prettier v3.0 rejects symlinks during glob expansion. All others follow
symlinks by default. Vale explicitly follows symlinks in a custom
Walk() function. mdsmith's M4 (default-deny) would match Prettier's
stance.
5. Config-as-code is "by design" everywhere in the Node.js world.
markdownlint, remark-lint, textlint, and Prettier all load .js/.cjs
config files via require()/import() with no sandboxing. This is
documented and intentional — the security boundary is "don't run linters
on untrusted repos." Vale partially sandboxes Tengo scripts (no os
module). mdsmith's CUE injection (finding 7) is less severe since CUE
has no I/O primitives.
6. Non-atomic writes are universal among tools with fix/write modes.
Prettier, markdownlint, remark-lint, and textlint all use bare
fs.writeFile with no temp-file-then-rename. Vale avoids the issue by
being read-only. mdsmith implementing M9 (atomic rename) would be
first-in-class.
7. Path traversal in link checking is acknowledged but unfixed.
remark-validate-links documents the risk ("this may be dangerous") but
ships no enforcement. markdownlint-rule-relative-links and
textlint-rule-no-dead-link have similar gaps. mdsmith's M5
(filepath.Rel boundary) would be a concrete improvement.
8. mdsmith's unique attack surfaces. Findings 6 (catalog content injection) and 10 (include size limit) are specific to mdsmith's directive system. No other tool in this comparison has equivalent features, so there is no industry precedent to compare against. These are novel attack surfaces that require mdsmith-specific mitigations.
If all 10 revised mitigations (M1–M10) are implemented, mdsmith would have the strongest security posture among the tools compared:
| Defense | mdsmith (post-fix) | Best current peer |
|---|---|---|
| File-size limit | M1: readFileLimited |
remark: advisory only |
| YAML alias bomb | M2: pre-scan &/* |
remark: maxAliasCount |
| Terminal sanitization | M3: strings.Map |
None |
| Symlink default-deny | M4: opt-in only | Prettier v3: reject |
| Path traversal boundary | M5: filepath.Rel |
None |
| Atomic writes | M9: temp+rename | None |