Skip to content

Commit 2ff8417

Browse files
authored
Merge pull request #1094 from fullsend-ai/feat/fetch-audit-logging-v2
feat: add fetch audit logging for remote resource tracking
2 parents 94db499 + 8e8f743 commit 2ff8417

3 files changed

Lines changed: 185 additions & 31 deletions

File tree

docs/plans/universal-harness-access.md

Lines changed: 23 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,12 +1175,14 @@ var RemoteResourcePolicy = ScanPolicy{
11751175

11761176
### 8. Audit Logging
11771177

1178-
**File:** `internal/audit/fetch_log.go` (new)
1178+
**File:** `internal/fetch/audit.go`
11791179

1180-
All fetches are logged to a structured log:
1180+
All fetches are logged to a structured JSONL log. The caller provides the log
1181+
path directly (better separation of concerns than env-var resolution inside the
1182+
logger). File permissions use `0o600` to match `security.AppendFinding`.
11811183

11821184
```go
1183-
package audit
1185+
package fetch
11841186

11851187
import (
11861188
"encoding/json"
@@ -1190,44 +1192,34 @@ import (
11901192
"time"
11911193
)
11921194

1193-
type FetchLog struct {
1194-
TraceID string `json:"trace_id"`
1195-
FetchTime time.Time `json:"fetch_time"`
1196-
URL string `json:"url"`
1197-
SHA256 string `json:"sha256"`
1198-
FetchType string `json:"fetch_type"` // "static" or "runtime"
1199-
AllowedBy string `json:"allowed_by"` // which allowed_remote_resources entry matched
1195+
type FetchAuditEntry struct {
1196+
TraceID string `json:"trace_id"`
1197+
FetchTime time.Time `json:"fetch_time"`
1198+
URL string `json:"url"`
1199+
SHA256 string `json:"sha256"`
1200+
FetchType string `json:"fetch_type"`
1201+
AllowedBy string `json:"allowed_by"`
1202+
CacheHit bool `json:"cache_hit"`
12001203
}
12011204

1202-
// LogFetch appends a fetch record to the audit log.
1203-
// Note: Audit logs are kept in user home directory for persistence across workspaces.
1204-
// This is configurable via FULLSEND_AUDIT_DIR environment variable.
1205-
func LogFetch(log FetchLog) error {
1206-
logDir := os.Getenv("FULLSEND_AUDIT_DIR")
1207-
if logDir == "" {
1208-
home, err := os.UserHomeDir()
1209-
if err != nil {
1210-
return fmt.Errorf("getting home directory for audit logs: %w", err)
1211-
}
1212-
logDir = filepath.Join(home, ".cache", "fullsend", "audit")
1213-
}
1214-
if err := os.MkdirAll(logDir, 0755); err != nil {
1205+
func AppendFetchAudit(logPath string, entry FetchAuditEntry) error {
1206+
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
12151207
return fmt.Errorf("creating audit log directory: %w", err)
12161208
}
1217-
1218-
logPath := filepath.Join(logDir, "fetches.jsonl")
1219-
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
1209+
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
12201210
if err != nil {
1221-
return err
1211+
return fmt.Errorf("opening audit log: %w", err)
12221212
}
12231213
defer f.Close()
12241214

1225-
data, err := json.Marshal(log)
1215+
data, err := json.Marshal(entry)
12261216
if err != nil {
1227-
return fmt.Errorf("marshaling fetch log: %w", err)
1217+
return fmt.Errorf("marshaling audit entry: %w", err)
12281218
}
1229-
_, err = f.Write(append(data, '\n'))
1230-
return err
1219+
if _, err := fmt.Fprintf(f, "%s\n", data); err != nil {
1220+
return fmt.Errorf("writing audit entry: %w", err)
1221+
}
1222+
return nil
12311223
}
12321224
```
12331225

internal/fetch/audit.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package fetch
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"time"
9+
)
10+
11+
// FetchAuditEntry is a JSONL audit record for a remote resource fetch.
12+
type FetchAuditEntry struct {
13+
TraceID string `json:"trace_id"`
14+
FetchTime time.Time `json:"fetch_time"`
15+
URL string `json:"url"`
16+
SHA256 string `json:"sha256"`
17+
FetchType string `json:"fetch_type"`
18+
AllowedBy string `json:"allowed_by"`
19+
CacheHit bool `json:"cache_hit"`
20+
}
21+
22+
// AppendFetchAudit writes a fetch audit entry as a JSON line to the given log path.
23+
func AppendFetchAudit(logPath string, entry FetchAuditEntry) error {
24+
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
25+
return fmt.Errorf("creating audit log directory: %w", err)
26+
}
27+
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
28+
if err != nil {
29+
return fmt.Errorf("opening audit log: %w", err)
30+
}
31+
defer f.Close()
32+
33+
data, err := json.Marshal(entry)
34+
if err != nil {
35+
return fmt.Errorf("marshaling audit entry: %w", err)
36+
}
37+
if _, err := fmt.Fprintf(f, "%s\n", data); err != nil {
38+
return fmt.Errorf("writing audit entry: %w", err)
39+
}
40+
return nil
41+
}

internal/fetch/audit_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package fetch
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestAppendFetchAudit_SingleEntry(t *testing.T) {
17+
logPath := filepath.Join(t.TempDir(), "audit.jsonl")
18+
19+
entry := FetchAuditEntry{
20+
TraceID: "trace-001",
21+
FetchTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC),
22+
URL: "https://example.com/resource",
23+
SHA256: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
24+
FetchType: "http_get",
25+
AllowedBy: "allowlist-rule-1",
26+
CacheHit: false,
27+
}
28+
29+
err := AppendFetchAudit(logPath, entry)
30+
require.NoError(t, err)
31+
32+
data, err := os.ReadFile(logPath)
33+
require.NoError(t, err)
34+
35+
var got FetchAuditEntry
36+
err = json.Unmarshal([]byte(strings.TrimSpace(string(data))), &got)
37+
require.NoError(t, err)
38+
39+
assert.Equal(t, "trace-001", got.TraceID)
40+
assert.Equal(t, time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC), got.FetchTime)
41+
assert.Equal(t, "https://example.com/resource", got.URL)
42+
assert.Equal(t, "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", got.SHA256)
43+
assert.Equal(t, "http_get", got.FetchType)
44+
assert.Equal(t, "allowlist-rule-1", got.AllowedBy)
45+
assert.False(t, got.CacheHit)
46+
}
47+
48+
func TestAppendFetchAudit_MultipleEntries(t *testing.T) {
49+
logPath := filepath.Join(t.TempDir(), "audit.jsonl")
50+
51+
for i := range 3 {
52+
entry := FetchAuditEntry{
53+
TraceID: fmt.Sprintf("trace-%03d", i),
54+
FetchTime: time.Now().UTC(),
55+
URL: fmt.Sprintf("https://example.com/resource/%d", i),
56+
SHA256: "deadbeef",
57+
FetchType: "http_get",
58+
AllowedBy: "allowlist",
59+
CacheHit: i > 0,
60+
}
61+
err := AppendFetchAudit(logPath, entry)
62+
require.NoError(t, err)
63+
}
64+
65+
data, err := os.ReadFile(logPath)
66+
require.NoError(t, err)
67+
68+
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
69+
assert.Len(t, lines, 3)
70+
71+
for _, line := range lines {
72+
var got FetchAuditEntry
73+
err := json.Unmarshal([]byte(line), &got)
74+
assert.NoError(t, err)
75+
}
76+
}
77+
78+
func TestAppendFetchAudit_CreatesParentDirs(t *testing.T) {
79+
logPath := filepath.Join(t.TempDir(), "a", "b", "c", "audit.jsonl")
80+
81+
entry := FetchAuditEntry{
82+
TraceID: "trace-nested",
83+
FetchTime: time.Now().UTC(),
84+
URL: "https://example.com/nested",
85+
SHA256: "cafebabe",
86+
FetchType: "http_get",
87+
AllowedBy: "allowlist",
88+
CacheHit: true,
89+
}
90+
91+
err := AppendFetchAudit(logPath, entry)
92+
require.NoError(t, err)
93+
94+
_, err = os.Stat(logPath)
95+
assert.NoError(t, err)
96+
}
97+
98+
func TestAppendFetchAudit_JSONFields(t *testing.T) {
99+
entry := FetchAuditEntry{
100+
TraceID: "trace-fields",
101+
FetchTime: time.Now().UTC(),
102+
URL: "https://example.com/fields",
103+
SHA256: "fieldhash",
104+
FetchType: "http_get",
105+
AllowedBy: "allowlist",
106+
CacheHit: false,
107+
}
108+
109+
data, err := json.Marshal(entry)
110+
require.NoError(t, err)
111+
112+
var m map[string]interface{}
113+
err = json.Unmarshal(data, &m)
114+
require.NoError(t, err)
115+
116+
expectedKeys := []string{"trace_id", "fetch_time", "url", "sha256", "fetch_type", "allowed_by", "cache_hit"}
117+
for _, key := range expectedKeys {
118+
assert.Contains(t, m, key)
119+
}
120+
assert.Len(t, m, len(expectedKeys))
121+
}

0 commit comments

Comments
 (0)