Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions ASSURANCE_CASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ The following maps [OWASP Top 10](https://owasp.org/www-project-top-ten/) and [C
| Weakness | Applicability | Countermeasure |
|----------|---------------|----------------|
| **A03:2021 Injection** (CWE-78 OS Command Injection) | All `exec.Command` calls use `git` with explicit argument lists — no shell interpolation. `--end-of-options` prevents flag injection. | Mitigated |
| **A03:2021 Injection** (CWE-79 Cross-site Scripting) | Viewer template output is HTML-escaped by `html/template`. Defense-in-depth: every viewer response carries a strict Content-Security-Policy (`default-src 'self'`, no `unsafe-inline`) plus `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, and `Permissions-Policy` (`internal/viewer/securityheaders.go`). | Mitigated |
| **A01:2021 Broken Access Control** (CWE-22 Path Traversal) | Agent file-read tool validates paths with `pathutil.WithinBase()` before and after symlink resolution (`internal/tool/filereader.go:91-112`). | Mitigated |
| **A02:2021 Cryptographic Failures** | All API communication uses HTTPS/TLS 1.2+. Go's default TLS configuration is used without weakening. `InsecureSkipVerify` is never set. | Mitigated |
| **A07:2021 Auth Failures** (CWE-798 Hard-coded Credentials) | API keys are read exclusively from environment variables, never embedded in code or config files, never logged. | Mitigated |
Expand Down
35 changes: 35 additions & 0 deletions internal/viewer/securityheaders.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package viewer

import "net/http"

// contentSecurityPolicy locks the viewer down to first-party resources only.
// The viewer loads no third-party scripts, styles, fonts, or frames, so a
// strict same-origin policy holds without any 'unsafe-inline' relaxation
// (the previously-inline session script now lives in static/session.js).
// This mitigates injection of active content should any user- or LLM-supplied
// value ever escape HTML escaping in a template.
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self'; " +
"img-src 'self' data:; " +
"object-src 'none'; " +
"base-uri 'none'; " +
"frame-ancestors 'none'; " +
"form-action 'none'"

// securityHeaders wraps a handler and sets defense-in-depth response headers on
// every reply. These harden the local viewer's browser-facing surface (the
// session JSONL exposed here contains reviewed source code and the LLM's
// analysis of it). HSTS is intentionally omitted: the viewer serves plain HTTP
// on loopback, where HSTS is meaningless and would wrongly pin localhost.
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", contentSecurityPolicy)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
next.ServeHTTP(w, r)
})
}
54 changes: 54 additions & 0 deletions internal/viewer/securityheaders_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package viewer

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestSecurityHeadersSetsAllHeaders(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})

rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
securityHeaders(inner).ServeHTTP(rec, req)

want := map[string]string{
"Content-Security-Policy": contentSecurityPolicy,
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Permissions-Policy": "geolocation=(), camera=(), microphone=()",
}
for k, v := range want {
if got := rec.Header().Get(k); got != v {
t.Errorf("header %q = %q, want %q", k, got, v)
}
}
}

// The viewer serves plain HTTP on loopback; HSTS would wrongly pin localhost.
func TestSecurityHeadersOmitsHSTS(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
securityHeaders(inner).ServeHTTP(rec, req)

if got := rec.Header().Get("Strict-Transport-Security"); got != "" {
t.Errorf("HSTS should not be set on the loopback viewer, got %q", got)
}
}

// The CSP must stay strict: no 'unsafe-inline'/'unsafe-eval' relaxation, since
// the formerly-inline session script now lives in static/session.js.
func TestContentSecurityPolicyIsStrict(t *testing.T) {
for _, bad := range []string{"unsafe-inline", "unsafe-eval", "*"} {
if strings.Contains(contentSecurityPolicy, bad) {
t.Errorf("CSP unexpectedly contains %q: %s", bad, contentSecurityPolicy)
}
}
}
7 changes: 5 additions & 2 deletions internal/viewer/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
"time"
)

//go:embed templates/*.html static/style.css
//go:embed templates/*.html static/style.css static/session.js
var assets embed.FS

func StartServer(addr string) error {
Expand Down Expand Up @@ -54,9 +54,12 @@ func StartServer(addr string) error {
allowed := resolveAllowedHostsFromEnv(addr)
guarded := hostGuard(allowed, mux)

// Outermost layer: set defense-in-depth security headers on every response.
handler := securityHeaders(guarded)

srv := &http.Server{
Addr: addr,
Handler: guarded,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revisado

Handler: handler,
}

fmt.Printf("\nOpen browser: http://%s\n", DisplayAddr(addr))
Expand Down
27 changes: 27 additions & 0 deletions internal/viewer/static/session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
document.querySelectorAll('.response-text').forEach(function(el) {
const text = el.textContent;
const esc = function(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
};
const codeBlocks = [];
let html = esc(text);
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
codeBlocks.push(code.replace(/^\n|\n$/g, ''));
return '%%CODEBLOCK_' + (codeBlocks.length - 1) + '%%';
});
Comment thread
lizhengfeng101 marked this conversation as resolved.
Outdated
html = html
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/^### (.+)$/gm, '<div class="md-h3">$1</div>')
.replace(/^## (.+)$/gm, '<div class="md-h2">$1</div>')
.replace(/^# (.+)$/gm, '<div class="md-h1">$1</div>')
.replace(/^[-*] (.+)$/gm, '<div class="md-li">&bull; $1</div>')
.replace(/\n{2,}/g, '<br><br>')
.replace(/\n/g, '<br>');
codeBlocks.forEach(function(code, i) {
html = html.replace('%%CODEBLOCK_' + i + '%%',
'<pre class="code-block"><code>' + code + '</code></pre>');
});
el.innerHTML = html;
});
30 changes: 1 addition & 29 deletions internal/viewer/templates/session.html
Original file line number Diff line number Diff line change
Expand Up @@ -231,35 +231,7 @@ <h4 class="task-type-label">
{{end}}
</div>

<script>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Factory

document.querySelectorAll('.response-text').forEach(function(el) {
var text = el.textContent;
var esc = function(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
};
var codeBlocks = [];
var html = esc(text);
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
codeBlocks.push(code.replace(/^\n|\n$/g, ''));
return '%%CODEBLOCK_' + (codeBlocks.length - 1) + '%%';
});
html = html
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/^### (.+)$/gm, '<div class="md-h3">$1</div>')
.replace(/^## (.+)$/gm, '<div class="md-h2">$1</div>')
.replace(/^# (.+)$/gm, '<div class="md-h1">$1</div>')
.replace(/^[-*] (.+)$/gm, '<div class="md-li">&bull; $1</div>')
.replace(/\n{2,}/g, '<br><br>')
.replace(/\n/g, '<br>');
codeBlocks.forEach(function(code, i) {
html = html.replace('%%CODEBLOCK_' + i + '%%',
'<pre class="code-block"><code>' + code + '</code></pre>');
});
el.innerHTML = html;
});
</script>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

,,,

<script src="/static/session.js"></script>
</main>
</body>
</html>
Loading