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
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
24 changes: 24 additions & 0 deletions internal/viewer/static/session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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(/
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>