Implement security hardening batch (plan 83) - #126
Conversation
3c3d409 to
b65b4e0
Compare
There was a problem hiding this comment.
Pull request overview
This PR implements a set of security hardening changes across mdsmith’s output formatting, filesystem boundary enforcement, and file writing behavior to reduce terminal injection risk, path traversal, and partial writes.
Changes:
- Sanitize control characters in text diagnostic output to mitigate terminal control-sequence injection.
- Enforce project-root boundaries for cross-file link resolution and read required-structure schemas via
RootFSwith traversal validation. - Switch fixer writes to an atomic temp-file-then-rename strategy and set a default Go runtime memory limit.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| plan/83_security-hardening-batch.md | Marks plan 83 as complete and updates tasks/acceptance criteria. |
| PLAN.md | Updates the plan catalog status for plan 83. |
| internal/rules/requiredstructure/rule.go | Reads schema via RootFS with path validation to prevent out-of-root reads. |
| internal/rules/requiredstructure/rule_test.go | Adds tests for schema reading via RootFS and path rejection cases. |
| internal/rules/crossfilereferenceintegrity/rule.go | Adds RootDir boundary checks to skip links escaping the project root. |
| internal/rules/crossfilereferenceintegrity/rule_test.go | Adds tests for skipping traversal-above-root links and allowing in-root links. |
| internal/rules/catalog/rule.go | Adds warnings for potentially markdown-injecting front-matter values in catalogs. |
| internal/rules/catalog/rule_test.go | Adds tests for catalog injection warning behavior. |
| internal/output/text.go | Adds terminal sanitization and applies it to emitted text output and snippets. |
| internal/output/text_test.go | Adds tests for terminal sanitization in formatter output and source snippets. |
| internal/lint/file.go | Adds RootDir to lint.File to support root-boundary logic in rules. |
| internal/fix/fix.go | Populates RootDir/RootFS for fixer files and introduces atomicWriteFile. |
| internal/fix/fix_test.go | Adds tests for atomic write behavior and permissions. |
| internal/engine/runner.go | Populates RootDir in lint files when runner RootDir is set. |
| cmd/mdsmith/main.go | Sets a default process-level Go memory limit when not externally configured. |
Comments suppressed due to low confidence (1)
internal/rules/crossfilereferenceintegrity/rule.go:257
- The root-boundary check is purely lexical (Abs/Rel on the link path) and does not resolve symlinks. A link that stays under RootDir but targets a symlink inside RootDir pointing outside will still pass this check and then be read via os.Stat/os.ReadFile. If the intent is to prevent reading outside RootDir, consider evaluating symlinks (filepath.EvalSymlinks) before the Rel check or otherwise disallow symlink targets when RootDir is set.
// Reject links that traverse above the project root.
if f.RootDir != "" {
absRoot, errRoot := filepath.Abs(f.RootDir)
absResolved, errRes := filepath.Abs(path)
if errRoot == nil && errRes == nil {
rel, err := filepath.Rel(absRoot, absResolved)
if err != nil ||
rel == ".." ||
strings.HasPrefix(
rel,
".."+string(filepath.Separator),
) {
return targetFile{}, false
}
}
}
if _, err := os.Stat(path); err == nil {
return targetFile{
cacheKey: "os:" + path,
read: func() ([]byte, error) {
return os.ReadFile(path)
},
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
internal/rules/requiredstructure/rule.go:87
- When
f.RootFSis set,readSchemaFiletreatsschemaas a path relative to the project root, but other logic in this rule (e.g., schema self-skip viaisSchemaFile, and anyos.Stat/abs-path comparisons usingr.Schema) still interpretr.Schemaas an OS path relative to the current working directory. This can cause the rule to (a) fail to skip the schema file itself, or (b) warn about<?require?>in the schema, depending on where mdsmith is run from. Consider normalizingr.Schemato an absolute OS path based onf.RootDir(when set) and use that consistently for both reading and schema-file identity checks.
schData, err := readSchemaFile(f, r.Schema)
if err != nil {
return append(diags, r.diag(f.Path, 1,
fmt.Sprintf("cannot read schema %q: %v", r.Schema, err)))
}
09f3cad to
8099553
Compare
230e53c to
1ff20a8
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
internal/rules/catalog/rule.go:102
- Generate() computes catalog injection warnings into
diags, but if renderCatalogContent returns an error the function returns only the template-failure diagnostic and drops the previously collected warnings. Consider either appending the template error diagnostic todiagsbefore returning, or delaying injection checking until after template rendering succeeds, so diagnostics are consistent and no work is wasted.
// Warn about front-matter values that could inject Markdown structure.
var diags []lint.Diagnostic
diags = append(diags, checkCatalogInjection(filePath, line, entries)...)
_, hasRow := params["row"]
content, err := renderCatalogContent(params, entries, cols, hasRow)
if err != nil {
return "", []lint.Diagnostic{makeDiag(filePath, line,
fmt.Sprintf("generated section template execution failed: %v", err))}
}
1ff20a8 to
7fa0f0e
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
internal/rules/requiredstructure/rule.go:87
readSchemaFilereads the schema viaf.RootFS, but downstream logic still treatsr.Schemaas an OS path:parseSchema(schData, r.Schema)usesschemaPathfor resolving schema<?include?>fragments (which currently useos.ReadFile), andisSchemaFile(f.Path, r.Schema)usesos.Stat/Abson the raw string. IfRootFSis set andschemais configured as a root-relative path (the new intended mode), running mdsmith from a subdirectory can break schema includes and fail to recognize/skip the schema file itself. Consider resolving a rooted OS path (e.g.,filepath.Join(f.RootDir, clean)) to pass intoparseSchema/isSchemaFile, and/or updating schema include reads to usef.RootFSas well so resolution is consistent.
schData, err := readSchemaFile(f, r.Schema)
if err != nil {
return append(diags, r.diag(f.Path, 1,
fmt.Sprintf("cannot read schema %q: %v", r.Schema, err)))
}
7fa0f0e to
f2c4d57
Compare
f2c4d57 to
e85dd90
Compare
cdaafe9 to
7a7123d
Compare
7a7123d to
523320e
Compare
523320e to
618bcce
Compare
618bcce to
59f3f7d
Compare
59f3f7d to
e9a90a1
Compare
e9a90a1 to
43b5663
Compare
43b5663 to
a2d62eb
Compare
a2d62eb to
d4e2650
Compare
Six low-risk security improvements: A. ANSI sanitization: strip all C0/C1 control chars from diagnostic header fields (file, message) via sanitizeControl; source lines preserve tab via sanitizeSourceLine. Prevents terminal injection and output spoofing. B. Path traversal boundary: add RootDir to lint.File, populate from Runner/Fixer. Links resolving outside RootDir are silently skipped in cross-file-reference-integrity. Uses filepath.EvalSymlinks to prevent symlink-based traversal. C. Atomic writes: replace os.WriteFile with temp-file-then-rename (with fsync) in fix mode to reduce risk of partial writes. D. Schema read via fs.FS: use fs.ReadFile(f.RootFS, ...) for schema reads when available; reject absolute and ../ paths. E. Catalog injection warning: emit diagnostics when interpolated front-matter values contain embedded newlines or ]( sequences. Map keys iterated in sorted order for deterministic output. F. GOMEMLIMIT: set 512 MiB memory limit in run() to bound CUE evaluation; respects GOMEMLIMIT env var. G. Include/catalog size limit deferred to plan 81. https://claude.ai/code/session_012qG8CzpnHem9aZZNbi4XkR
d4e2650 to
e3ef2b8
Compare
Summary
Implements six of seven security hardening fixes from plan 83. Task G (include/catalog size limits) is deferred to plan 81.
sanitizeControl; source lines preserve tab viasanitizeSourceLine. Prevents terminal injection and output spoofing.RootDirtolint.File; links resolving outsideRootDirare silently skipped in cross-file-reference-integrity. Usesfilepath.EvalSymlinksto prevent symlink-based traversal. Root path resolved once perCheckcall for efficiency.os.WriteFilewith temp-file-then-rename (with fsync) in fix mode to reduce risk of partial writes on crash. Checks target writability before proceeding; propagates non-ENOENTStaterrors.fs.ReadFile(f.RootFS, ...)for schema reads when available; reject absolute and../paths. Uses cleaned path for consistent resolution.Rule.Check, notGenerate) when interpolated front-matter values contain embedded newlines or](sequences. Map keys iterated in sorted order for deterministic output.run()to bound CUE evaluation; respectsGOMEMLIMITenv var.Plan 83 status is 🔳 (in-progress) because task G depends on plan 81.
Test plan
go test ./...— all packages passgo tool golangci-lint run— 0 issuesmdsmith check .— 0 failuressanitizeControl(11 cases),sanitizeSourceLine(3 cases), header/source sanitization, path traversal boundary (2 integration + 8 unit), atomic write (5 cases), schema fs.FS read (3 cases), catalog injection (4 cases)https://claude.ai/code/session_012qG8CzpnHem9aZZNbi4XkR