Skip to content

Commit 4dd4d78

Browse files
committed
Add evidence bundle manifest
1 parent a52d5d2 commit 4dd4d78

8 files changed

Lines changed: 220 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
- Added project readiness status with pipeline summaries, export readiness, next actions, and `--json` output.
3535
- Added `openexit run` to execute assessment, mapping, full generation, validation, status reporting, and optional evidence bundle export for collected projects.
3636
- Added `openexit demo` with embedded redacted fixtures for complete release-binary demos across supported assessment paths.
37+
- Added a machine-readable evidence bundle manifest with build metadata, validation summary, and per-file SHA-256 digests.
3738
- Updated CI and release workflows to Node.js 24-native GitHub Actions.
3839

3940
## 0.1.0 - 2026-05-24

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ Included in the current implementation:
118118
- ArgoCD starter manifest.
119119
- Typed migration plan manifest and phase-gate Markdown plan.
120120
- Validation report with embedded JSON Schema checks, Grafana dashboard, Prometheus alert, OpenTelemetry collector, ArgoCD, Forgejo migration, identity realm/client, edge VCL/HAProxy/Coraza, and LiteLLM/vLLM candidate checks, YAML/JSON parsing, evidence ref checks, secret scan, and optional `promtool`/`kubeconform` checks.
121-
- Evidence bundle export.
121+
- Evidence bundle export with README, checksums, and a machine-readable manifest.
122122
- Evidence bundle path-safety checks that reject symlinks in exported project sections.
123123
- No-op assist provider and explicit opt-in LiteLLM assist.
124124
- GitHub Enterprise to Forgejo assessment path with fixture import and live repository inventory collection.

docs/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ The checked-in Datadog example can be refreshed with `make example VERSION=0.1.0
2929

3030
`openexit validate` performs typed consistency checks, embedded JSON Schema validation, Grafana dashboard candidate validation, Prometheus alert-rule candidate validation, OpenTelemetry collector candidate validation, ArgoCD candidate validation, Forgejo migration candidate validation, identity realm/client candidate validation, edge VCL/HAProxy/Coraza candidate validation, LiteLLM/vLLM candidate validation, YAML/JSON parse checks, evidence reference checks, secret scanning, and optional external tool checks when `promtool` or `kubeconform` are installed.
3131

32-
`openexit export` refuses to package symlinks from exported project sections, even with `--force`, so evidence bundles cannot accidentally include files from outside the project tree.
32+
`openexit export` refuses to package symlinks from exported project sections, even with `--force`, so evidence bundles cannot accidentally include files from outside the project tree. Exported zips include `openexit-evidence/manifest.json` with build metadata, project source/target, validation totals, and per-file SHA-256 digests, plus `checksums.txt` for archive-level verification.
3333

3434
Generate individual artifacts with `openexit generate --artifact <name>`. The primary Datadog path supports `mapping`, `assessment`, `risk-register`, `manual-review`, `cost-drivers`, `target-architecture`, `acceptance-criteria`, `rollback-plan`, `runbook`, `restore-drill-checklist`, `alert-shadowing-plan`, `migration-plan`, `grafana-dashboards`, `prometheus-rules`, `opentelemetry`, and `argocd`. The GitHub Enterprise path also supports `forgejo-migration-candidate`; the identity path also supports `realm-client-candidate`; the edge path also supports `vcl-candidates`, `haproxy-candidates`, and `coraza-rule-candidates`; the AI provider path also supports `litellm-config-candidate`.
3535

docs/release.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Release-blocking v0.1 requirements:
1717
- Grafana, Prometheus, OpenTelemetry, and ArgoCD candidate generators.
1818
- Typed migration plan manifest with assessment, pilot, shadow, and cutover phase gates.
1919
- Validation engine with embedded JSON Schema checks, Grafana dashboard, Prometheus alert, OpenTelemetry collector, ArgoCD, Forgejo migration, identity realm/client, edge VCL/HAProxy/Coraza, and LiteLLM/vLLM candidate checks, YAML/JSON parsing, evidence refs, secret scan, optional promtool, and optional kubeconform.
20-
- Evidence bundle export with checksums and OpenExit version metadata.
20+
- Evidence bundle export with checksums, a machine-readable manifest, and OpenExit version metadata.
2121
- No-op AI assist and optional external assist behind explicit opt-in.
2222
- Documentation, examples, CI, release draft workflow, and reproducible release artifacts.
2323

@@ -60,6 +60,7 @@ The AI provider path is complete for local fixture assessment workflows and incl
6060
- [ ] `validation/validation-report.md` includes `litellm-config-candidate: passed` for the OpenAI/Anthropic to vLLM/LiteLLM pipeline.
6161
- [ ] `CHANGELOG.md` has a `0.1.0` section.
6262
- [ ] Exported bundle README includes version, commit, build date, bundle timestamp, and candidate warning.
63+
- [ ] Exported bundle `manifest.json` includes build metadata, project source/target, validation totals, and per-file SHA-256 digests.
6364
- [ ] Export refuses symlinks in exported project sections, including when `--force` is used.
6465
- [ ] No credentials, tokens, passwords, or private keys are present in fixtures, generated files, docs, or bundles.
6566
- [ ] Draft release notes have been reviewed.

docs/security.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ OpenExit is designed for local-first assessment work.
1414
- The Anthropic collector records aggregate Messages API usage and server tool metadata only; it never requests or stores raw prompts, responses, or API key values.
1515
- Raw source evidence is redacted before it is written locally.
1616
- Validation scans JSON, YAML, Markdown, text, VCL, HAProxy, and Coraza-style generated artifacts for secret-like values.
17-
- Exported bundles contain local manifests, generated candidates, validation output, checksums, and redacted evidence.
17+
- Exported bundles contain local manifests, generated candidates, validation output, a machine-readable bundle manifest, checksums, and redacted evidence.
1818
- Export refuses symlinks in exported project sections so bundle contents cannot follow paths outside the project tree.
1919
- AI assist is optional, disabled by default, and never part of deterministic validation or export.
2020

examples/datadog-to-grafana/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ make example VERSION=0.1.0-dev
99
```
1010

1111
This refreshes `examples/datadog-to-grafana/output/` and writes an ignored bundle to `examples/datadog-to-grafana/openexit-example.zip`.
12+
The bundle includes `openexit-evidence/manifest.json` for machine-readable audit metadata and per-file digests.
1213

1314
For a release-binary smoke test that does not depend on repository-local fixtures, run:
1415

internal/app/e2e_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,6 +1125,7 @@ func expectedProjectFiles() []string {
11251125
func expectedBundleFiles() []string {
11261126
files := []string{
11271127
"openexit-evidence/README.md",
1128+
"openexit-evidence/manifest.json",
11281129
"openexit-evidence/checksums.txt",
11291130
}
11301131
for _, rel := range expectedProjectFiles() {
@@ -1167,9 +1168,78 @@ func assertBundle(bundlePath string, expected []string) error {
11671168
return &checksumError{message: "bundle README missing marker " + string(marker)}
11681169
}
11691170
}
1171+
if err := verifyBundleManifest(files); err != nil {
1172+
return err
1173+
}
11701174
return verifyChecksums(files)
11711175
}
11721176

1177+
func verifyBundleManifest(files map[string][]byte) error {
1178+
data, ok := files["openexit-evidence/manifest.json"]
1179+
if !ok {
1180+
return &missingBundleFileError{name: "openexit-evidence/manifest.json"}
1181+
}
1182+
var manifest struct {
1183+
APIVersion string `json:"apiVersion"`
1184+
Kind string `json:"kind"`
1185+
Build struct {
1186+
Version string `json:"version"`
1187+
Commit string `json:"commit"`
1188+
Date string `json:"date"`
1189+
} `json:"build"`
1190+
Project struct {
1191+
Name string `json:"name"`
1192+
Source string `json:"source"`
1193+
Target string `json:"target"`
1194+
} `json:"project"`
1195+
Validation struct {
1196+
Status string `json:"status"`
1197+
Checks int `json:"checks"`
1198+
Passed int `json:"passed"`
1199+
Failed int `json:"failed"`
1200+
Warnings int `json:"warnings"`
1201+
} `json:"validation"`
1202+
Files []struct {
1203+
Path string `json:"path"`
1204+
Size int64 `json:"size"`
1205+
SHA256 string `json:"sha256"`
1206+
} `json:"files"`
1207+
}
1208+
if err := json.Unmarshal(data, &manifest); err != nil {
1209+
return err
1210+
}
1211+
if manifest.APIVersion != "openexit.dev/v1alpha1" || manifest.Kind != "EvidenceBundle" {
1212+
return &checksumError{message: "unexpected bundle manifest identity"}
1213+
}
1214+
if manifest.Project.Source != "datadog" || manifest.Project.Target != "grafana-lgtm" {
1215+
return &checksumError{message: "bundle manifest has wrong source/target"}
1216+
}
1217+
if manifest.Validation.Status != "passed" || manifest.Validation.Checks == 0 || manifest.Validation.Failed != 0 {
1218+
return &checksumError{message: "bundle manifest has wrong validation summary"}
1219+
}
1220+
if manifest.Build.Version == "" || manifest.Build.Commit == "" || manifest.Build.Date == "" {
1221+
return &checksumError{message: "bundle manifest missing build metadata"}
1222+
}
1223+
seen := map[string]bool{}
1224+
for _, entry := range manifest.Files {
1225+
data, ok := files["openexit-evidence/"+entry.Path]
1226+
if !ok {
1227+
return &missingBundleFileError{name: "openexit-evidence/" + entry.Path}
1228+
}
1229+
sum := sha256.Sum256(data)
1230+
if hex.EncodeToString(sum[:]) != entry.SHA256 || int64(len(data)) != entry.Size {
1231+
return &checksumError{message: "bundle manifest digest mismatch for " + entry.Path}
1232+
}
1233+
seen[entry.Path] = true
1234+
}
1235+
for _, rel := range []string{"openexit.yaml", "validation/openexit.validation.yaml", "generated-config/prometheus/rules/datadog-monitor-candidates.yaml"} {
1236+
if !seen[rel] {
1237+
return &missingBundleFileError{name: "bundle manifest entry " + rel}
1238+
}
1239+
}
1240+
return nil
1241+
}
1242+
11731243
func verifyChecksums(files map[string][]byte) error {
11741244
checksumData, ok := files["openexit-evidence/checksums.txt"]
11751245
if !ok {

internal/export/bundle.go

Lines changed: 143 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"bytes"
66
"crypto/sha256"
77
"encoding/hex"
8+
"encoding/json"
89
"fmt"
910
"io"
1011
"os"
@@ -15,6 +16,7 @@ import (
1516

1617
"github.com/RamazanKara/openexit/internal/validate"
1718
"github.com/RamazanKara/openexit/internal/version"
19+
"gopkg.in/yaml.v3"
1820
)
1921

2022
type Options struct {
@@ -42,7 +44,43 @@ func Bundle(opts Options) error {
4244
if err != nil {
4345
return err
4446
}
45-
return writeZip(opts.ProjectDir, opts.Out, files)
47+
return writeZip(opts.ProjectDir, opts.Out, files, report)
48+
}
49+
50+
type BundleManifest struct {
51+
APIVersion string `json:"apiVersion"`
52+
Kind string `json:"kind"`
53+
GeneratedAt string `json:"generatedAt"`
54+
Build BundleBuild `json:"build"`
55+
Project BundleProject `json:"project"`
56+
Validation BundleValidation `json:"validation"`
57+
Files []BundleManifestFile `json:"files"`
58+
}
59+
60+
type BundleBuild struct {
61+
Version string `json:"version"`
62+
Commit string `json:"commit"`
63+
Date string `json:"date"`
64+
}
65+
66+
type BundleProject struct {
67+
Name string `json:"name"`
68+
Source string `json:"source,omitempty"`
69+
Target string `json:"target,omitempty"`
70+
}
71+
72+
type BundleValidation struct {
73+
Status string `json:"status"`
74+
Checks int `json:"checks"`
75+
Passed int `json:"passed"`
76+
Failed int `json:"failed"`
77+
Warnings int `json:"warnings"`
78+
}
79+
80+
type BundleManifestFile struct {
81+
Path string `json:"path"`
82+
Size int64 `json:"size"`
83+
SHA256 string `json:"sha256"`
4684
}
4785

4886
func bundleFiles(projectDir string) ([]string, error) {
@@ -84,7 +122,7 @@ func bundleFiles(projectDir string) ([]string, error) {
84122
return files, err
85123
}
86124

87-
func writeZip(projectDir, out string, files []string) (err error) {
125+
func writeZip(projectDir, out string, files []string, report *validate.Report) (err error) {
88126
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil && filepath.Dir(out) != "." {
89127
return err
90128
}
@@ -104,6 +142,7 @@ func writeZip(projectDir, out string, files []string) (err error) {
104142
}
105143
}()
106144
checksums := map[string]string{}
145+
var manifestFiles []BundleManifestFile
107146
prefix := "openexit-evidence"
108147
fixedTime := time.Date(2026, 5, 24, 0, 0, 0, 0, time.UTC)
109148
generatedAt := time.Now().UTC().Format(time.RFC3339)
@@ -118,20 +157,25 @@ func writeZip(projectDir, out string, files []string) (err error) {
118157
return err
119158
}
120159
sum := sha256.Sum256(data)
121-
checksums[filepath.ToSlash(rel)] = hex.EncodeToString(sum[:])
160+
digest := hex.EncodeToString(sum[:])
161+
relName := filepath.ToSlash(rel)
162+
checksums[relName] = digest
163+
manifestFiles = append(manifestFiles, BundleManifestFile{
164+
Path: relName,
165+
Size: int64(len(data)),
166+
SHA256: digest,
167+
})
122168
}
123-
var checks bytes.Buffer
124-
keys := make([]string, 0, len(checksums))
125-
for path := range checksums {
126-
keys = append(keys, path)
127-
}
128-
sort.Strings(keys)
129-
for _, path := range keys {
130-
_, _ = fmt.Fprintf(&checks, "%s %s\n", checksums[path], path)
169+
170+
manifestData, err := json.MarshalIndent(buildManifest(projectDir, generatedAt, report, manifestFiles), "", " ")
171+
if err != nil {
172+
return err
131173
}
132-
if err := addZipFile(zw, prefix+"/checksums.txt", fixedTime, checks.Bytes()); err != nil {
174+
manifestData = append(manifestData, '\n')
175+
if err := addSyntheticZipFile(zw, prefix, "manifest.json", fixedTime, manifestData, checksums); err != nil {
133176
return err
134177
}
178+
135179
readme := fmt.Sprintf(`# OpenExit Evidence Bundle
136180
137181
Generated by OpenExit.
@@ -142,8 +186,94 @@ Generated by OpenExit.
142186
- Bundle generated at: %s
143187
144188
This bundle contains local inventory, assessment, generated candidates, validation, and redacted evidence. Candidate configs are not production-ready until reviewed.
189+
The machine-readable bundle manifest is available at manifest.json.
145190
`, version.Version, version.Commit, version.Date, generatedAt)
146-
return addZipFile(zw, prefix+"/README.md", fixedTime, []byte(readme))
191+
if err := addSyntheticZipFile(zw, prefix, "README.md", fixedTime, []byte(readme), checksums); err != nil {
192+
return err
193+
}
194+
195+
var checks bytes.Buffer
196+
keys := make([]string, 0, len(checksums))
197+
for path := range checksums {
198+
keys = append(keys, path)
199+
}
200+
sort.Strings(keys)
201+
for _, path := range keys {
202+
_, _ = fmt.Fprintf(&checks, "%s %s\n", checksums[path], path)
203+
}
204+
return addZipFile(zw, prefix+"/checksums.txt", fixedTime, checks.Bytes())
205+
}
206+
207+
func buildManifest(projectDir, generatedAt string, report *validate.Report, files []BundleManifestFile) BundleManifest {
208+
return BundleManifest{
209+
APIVersion: "openexit.dev/v1alpha1",
210+
Kind: "EvidenceBundle",
211+
GeneratedAt: generatedAt,
212+
Build: BundleBuild{
213+
Version: version.Version,
214+
Commit: version.Commit,
215+
Date: version.Date,
216+
},
217+
Project: readBundleProject(projectDir),
218+
Validation: validationSummary(report),
219+
Files: files,
220+
}
221+
}
222+
223+
func readBundleProject(projectDir string) BundleProject {
224+
project := BundleProject{Name: filepath.Base(projectDir)}
225+
data, err := os.ReadFile(filepath.Join(projectDir, "openexit.yaml"))
226+
if err != nil {
227+
return project
228+
}
229+
var manifest struct {
230+
Metadata struct {
231+
Name string `yaml:"name"`
232+
} `yaml:"metadata"`
233+
Source struct {
234+
Type string `yaml:"type"`
235+
} `yaml:"source"`
236+
Target struct {
237+
Type string `yaml:"type"`
238+
} `yaml:"target"`
239+
}
240+
if err := yaml.Unmarshal(data, &manifest); err != nil {
241+
return project
242+
}
243+
if strings.TrimSpace(manifest.Metadata.Name) != "" {
244+
project.Name = manifest.Metadata.Name
245+
}
246+
project.Source = manifest.Source.Type
247+
project.Target = manifest.Target.Type
248+
return project
249+
}
250+
251+
func validationSummary(report *validate.Report) BundleValidation {
252+
summary := BundleValidation{Status: reportStatus(report)}
253+
if report == nil {
254+
return summary
255+
}
256+
summary.Checks = len(report.Checks)
257+
for _, check := range report.Checks {
258+
switch check.Status {
259+
case "passed":
260+
summary.Passed++
261+
case "failed":
262+
summary.Failed++
263+
case "warning":
264+
summary.Warnings++
265+
}
266+
}
267+
return summary
268+
}
269+
270+
func addSyntheticZipFile(zw *zip.Writer, prefix, rel string, modTime time.Time, data []byte, checksums map[string]string) error {
271+
if err := addZipFile(zw, prefix+"/"+rel, modTime, data); err != nil {
272+
return err
273+
}
274+
sum := sha256.Sum256(data)
275+
checksums[rel] = hex.EncodeToString(sum[:])
276+
return nil
147277
}
148278

149279
func addZipFile(zw *zip.Writer, name string, modTime time.Time, data []byte) error {

0 commit comments

Comments
 (0)