How OpenVAPT Is Built
A walk through the source, module by module. Every excerpt is read from the repository when this site is built, so line numbers and links always match the commit in the footer.
See It Run on Your Machine
Simulated Walkthrough Using the Built-In Demo Lab. Every line below is real OpenVAPT output, trimmed for length where marked.
Server
HOST-SSH-001SSH Allows Direct Root LoginHigh
HOST-SSH-003SSH Permits Empty PasswordsCritical
HOST-USR-003Sudo Rules Without Password (NOPASSWD)High
HOST-KRN-001Address Space Layout Randomisation Not Fully EnabledMedium
HOST-AUD-001Audit Daemon Not Enabled at BootLow
Network
NET-DB-002Redis Authentication and Network BindingCritical
NET-SVC-001Cleartext Legacy Services ListeningHigh
NET-FW-001No Active Host FirewallMedium
NET-TLS-001Deprecated TLS 1.0 or 1.1 AcceptedMedium
Web
WEB-EXP-002Environment or Secrets File ExposedCritical
WEB-EXP-001Git Repository Exposed on the WebsiteHigh
WEB-HDR-002Missing Content Security PolicyMedium
WEB-HDR-004Missing X-Content-Type-OptionsLow
OpenVAPT Self-Assessment Report
lab-vulnerable (simulated) - Ubuntu 18.04.6 LTS - 2026-10-14 02:57 UTC
Executive Summary
Result: 305 checks failed (14 Critical, 71 High, 88 Medium, 123 Low, 9 Info) and 2 passed. Critical weaknesses require immediate attention.
HighSSH Allows Direct Root LoginHOST-SSH-001
Why it matters: Root is the one account name every attacker knows. Allowing it over SSH turns any password guess into full control of the server, and removes the audit trail of which person used root.
Evidence, sshd: Root may log in over SSH with a password
permitrootlogin yes
Fix: In /etc/ssh/sshd_config set: PermitRootLogin no (or prohibit-password if key-based root login is truly required). Log in as a named user and use sudo. Then run: sshd -t && systemctl reload sshd
Verify: sshd -T | grep -i permitrootlogin should print 'permitrootlogin no'.
Learn: P2: SSH Security, https://openvapt.com/academy/p2.html
Evidence: secrets (passwords, keys, tokens, hashes) were redacted before writing this report.
Read It. Run It on Your Own Machine.
Free for your own use, on machines you own or control, 100% at your own risk.
Read the Walkthrough Transcript
This is a simulated walkthrough using the built-in demo lab. It is built only from real OpenVAPT output; long output is trimmed where marked and every value is kept as printed.
- Install. Run
curl -fsSL https://raw.githubusercontent.com/OpenVAPT/openvapt/main/scripts/install.sh | sh(Download Only). The installer picks the most stable channel that has a release, downloads the Linux binary and SHA256SUMS, verifies the checksum, checks the Sigstore signature when cosign is installed, and installs to~/.local/binwithout root. While there is no Stable release it also prints two pre-release warnings (trimmed in the scene). It prints: "Installed (Download Only). Nothing has been scanned." To scan straight after installing, Download and Scan adds--run:curl -fsSL https://raw.githubusercontent.com/OpenVAPT/openvapt/main/scripts/install.sh | sh -s -- --run. - Accept. Run
sudo openvapt scan. OpenVAPT prints the terms and ten numbered acknowledgements, then asks you to typeI ACCEPT; anything else cancels and nothing is stored. It then prints "Scanning this machine with 307 checks (read-only, local only)...". - Checks. The read-only checks cover three domains: Server (for example HOST-SSH-001, HOST-USR-003, HOST-KRN-001), Network (NET-DB-002, NET-FW-001, NET-TLS-001) and Web (WEB-EXP-002, WEB-HDR-002). The terminal prints only the start line and the summary; the scene illustrates the work in between.
- Summary.
openvapt demoscans a simulated vulnerable lab server in memory: 307 checks run, 2 passed, 305 failed, 0 incomplete; failed by severity Critical 14, High 71, Medium 88, Low 123. It writes report.html and report.md only on this machine, lists every report path with a hint on how to open the HTML report, and exits with code 3, meaning findings at or above High severity were found. - Report. The HTML report has an executive summary, severity counts and a card for each finding. The HOST-SSH-001 card shows the evidence
permitrootlogin yes, the fix (setPermitRootLogin no, thensshd -t && systemctl reload sshd), how to verify it, and the Academy module P2: SSH Security. Secrets in evidence are redacted before the report is written. - Explain and Learn.
openvapt explain HOST-SSH-001prints what the check does, why it matters, how it detects, how to fix and verify it, and its references.openvapt learnlists the offline Academy: 3 Foundations, 22 Practitioner and 6 Applied modules, each with a quiz. - Next. Download OpenVAPT or start the Academy.
The Source, Module by Module
Design in One Paragraph
Checks are small Go files that describe one weakness and how to detect it. They never touch the operating system directly. Instead they receive a read-only System, whose network access is confined by the scope guard to this machine. The engine runs the checks, redacts secrets and writes the report to the local disk. The same checks run against simulated lab servers in the tests, which proves each one detects its weakness and stays silent once it is fixed.
Scope Guard
The security boundary. Every network connection the tool makes passes through here, and anything that is not a loopback address is refused before a single packet is sent.
- Host names are never resolved, only literal loopback IPs count, so /etc/hosts or DNS tricks cannot redirect a scan.
- The check runs on every dial, including HTTP redirects, and again on the connected peer as defence in depth.
- There is no flag to turn it off. The unit test proves remote addresses are refused.
internal/scope/scope.go · IsLoopback · Lines 20-26View on GitHub at This Commit
| 20 | // IsLoopback reports whether host is a literal loopback IP address. |
| 21 | // Host names are deliberately NOT resolved: "localhost" could be pointed at |
| 22 | // another machine through /etc/hosts or DNS, so only IP literals count. |
| 23 | func IsLoopback(host string) bool { |
| 24 | ip := net.ParseIP(host) |
| 25 | return ip != nil && ip.IsLoopback() |
| 26 | } |
internal/scope/scope.go · CheckAddr · Lines 28-38View on GitHub at This Commit
| 28 | // CheckAddr validates a "host:port" address before any connection is made. |
| 29 | func CheckAddr(addr string) error { |
| 30 | host, _, err := net.SplitHostPort(addr) |
| 31 | if err != nil { |
| 32 | return fmt.Errorf("%w: %v", ErrNotLocal, err) |
| 33 | } |
| 34 | if !IsLoopback(host) { |
| 35 | return fmt.Errorf("%w: %q", ErrNotLocal, host) |
| 36 | } |
| 37 | return nil |
| 38 | } |
internal/scope/scope.go · DialContext · Lines 46-64View on GitHub at This Commit
| 46 | // DialContext connects only if addr is a loopback address. The check runs on |
| 47 | // the address actually being dialled, so redirects to other hosts fail too. |
| 48 | func (d Dialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { |
| 49 | if err := CheckAddr(addr); err != nil { |
| 50 | return nil, err |
| 51 | } |
| 52 | nd := net.Dialer{Timeout: d.Timeout} |
| 53 | conn, err := nd.DialContext(ctx, network, addr) |
| 54 | if err != nil { |
| 55 | return nil, err |
| 56 | } |
| 57 | // Defence in depth: confirm the connected peer really is loopback. Any |
| 58 | // peer address that is not a loopback TCP address fails closed. |
| 59 | if !peerIsLoopback(conn.RemoteAddr()) { |
| 60 | _ = conn.Close() |
| 61 | return nil, ErrNotLocal |
| 62 | } |
| 63 | return conn, nil |
| 64 | } |
System Access Layer
Checks never call the operating system directly. They receive a System with a small read-only interface, which makes the tool read-only by construction and lets the same check run against the real machine, a dry-run recorder or a simulated lab.
- There is no write, delete or restart operation in the interface to call.
- Commands must match an exact allow-list of read-only programs; php may only run echo ini_get() expressions.
- HTTP requests go to 127.0.0.1 with the site name as Host header, redirects are reported but never followed, and proxies are disabled.
internal/sys/sys.go · IsAllowed · Lines 125-161View on GitHub at This Commit
| 125 | // IsAllowed reports whether a command line matches the allow-list. |
| 126 | func IsAllowed(name string, args []string) bool { |
| 127 | full := append([]string{name}, args...) |
| 128 | if name == "php" && len(args) == 2 && args[0] == "-r" { |
| 129 | return safePHP(args[1]) |
| 130 | } |
| 131 | // sshd -T -C evaluates the configuration for a hypothetical connection; no |
| 132 | // connection is made. 192.0.2.1 is a reserved documentation address |
| 133 | // (RFC 5737) used only to evaluate Match rules for non-local clients. |
| 134 | if name == "sshd" && len(args) == 3 && args[0] == "-T" && args[1] == "-C" { |
| 135 | // The context must be the fixed shape checked by ValidSSHDContext; |
| 136 | // callers build it only from validated Match values. |
| 137 | return ValidSSHDContext(args[2]) |
| 138 | } |
| 139 | if name == "systemctl" && len(args) == 2 && (args[0] == "is-active" || args[0] == "is-enabled") { |
| 140 | return validUnit(args[1]) |
| 141 | } |
| 142 | for _, a := range allowed { |
| 143 | if len(full) != len(a) { |
| 144 | continue |
| 145 | } |
| 146 | match := true |
| 147 | for i := range a { |
| 148 | if full[i] != a[i] { |
| 149 | match = false |
| 150 | break |
| 151 | } |
| 152 | } |
| 153 | if match { |
| 154 | if name == "php" && len(args) > 1 && args[0] == "-r" && !safePHP(args[1]) { |
| 155 | return false |
| 156 | } |
| 157 | return true |
| 158 | } |
| 159 | } |
| 160 | return false |
| 161 | } |
internal/sys/sys.go · safePHP · Lines 201-201View on GitHub at This Commit
| 201 | func safePHP(expr string) bool { return safePHPRe.MatchString(expr) } |
internal/sys/sys.go · NewLive · Lines 208-224View on GitHub at This Commit
| 208 | // NewLive builds a Live system whose network access is confined to loopback. |
| 209 | func NewLive() *Live { |
| 210 | d := scope.Dialer{Timeout: 5 * time.Second} |
| 211 | tr := &http.Transport{ |
| 212 | DialContext: d.DialContext, |
| 213 | Proxy: nil, // never use a proxy: it would leave the machine |
| 214 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 -- certificates are inspected and reported, never trusted; loopback only |
| 215 | DisableKeepAlives: true, |
| 216 | } |
| 217 | return &Live{client: &http.Client{ |
| 218 | Transport: tr, |
| 219 | Timeout: 10 * time.Second, |
| 220 | CheckRedirect: func(*http.Request, []*http.Request) error { |
| 221 | return http.ErrUseLastResponse // report redirects, never follow them |
| 222 | }, |
| 223 | }} |
| 224 | } |
Check Registry
Defines what a check is: its id, severity, CVSS vector, rationale, detection logic, fix, verification step, references and standards mapping. Registration refuses checks with missing documentation or no citations.
- Each check records the file it is defined in, which is how this website cites its source.
- Severities follow the CVSS v3.1 qualitative scale.
internal/check/check.go · Register · Lines 127-160View on GitHub at This Commit
| 127 | // Register adds a check to the global registry. It panics on duplicate ids |
| 128 | // or missing metadata, so a broken check fails the build's tests at once. |
| 129 | func Register(c *Check) { |
| 130 | if c.ID == "" || c.Title == "" || c.Run == nil || c.Fix == "" || c.Rationale == "" { |
| 131 | panic(fmt.Sprintf("check %q is missing required fields", c.ID)) |
| 132 | } |
| 133 | applyLegacy(c) |
| 134 | applyFrameworks(c) |
| 135 | if c.Category == "" || CategoryByID(c.Category).ID == "" || c.Effort == "" { |
| 136 | panic(fmt.Sprintf("check %q must have a valid Category and Effort", c.ID)) |
| 137 | } |
| 138 | score, err := BaseScore(c.CVSS) |
| 139 | if err != nil { |
| 140 | panic(fmt.Sprintf("check %q: %v", c.ID, err)) |
| 141 | } |
| 142 | if want := SeverityFor(score); c.Severity != want { |
| 143 | panic(fmt.Sprintf("check %q: severity %s does not match its CVSS v3.1 base score %.1f (%s)", c.ID, c.Severity, score, want)) |
| 144 | } |
| 145 | if len(c.Refs) == 0 { |
| 146 | panic(fmt.Sprintf("check %q must cite at least one reference", c.ID)) |
| 147 | } |
| 148 | _, file, _, _ := runtime.Caller(1) |
| 149 | if i := strings.Index(file, "/internal/checks/"); i >= 0 { |
| 150 | c.SourceFile = file[i+1:] |
| 151 | } else { |
| 152 | c.SourceFile = file |
| 153 | } |
| 154 | mu.Lock() |
| 155 | defer mu.Unlock() |
| 156 | if _, dup := registry[c.ID]; dup { |
| 157 | panic("duplicate check id " + c.ID) |
| 158 | } |
| 159 | registry[c.ID] = c |
| 160 | } |
Collectors
Shared read-only discovery: the effective SSH configuration, kernel parameters from /proc/sys, listening sockets decoded from /proc/net, and the websites configured in Apache, nginx or LiteSpeed.
- Listening sockets are read from the kernel's own tables, not by port scanning.
- Websites are discovered from local configuration, which is what keeps web checks on this machine.
internal/checks/collect/collect.go · parseProcAddr · Lines 496-516View on GitHub at This Commit
| 496 | // parseProcAddr decodes "0100007F:0050" (little-endian hex IP : hex port). |
| 497 | func parseProcAddr(s string) (net.IP, int, error) { |
| 498 | parts := strings.Split(s, ":") |
| 499 | if len(parts) != 2 { |
| 500 | return nil, 0, fmt.Errorf("bad address %q", s) |
| 501 | } |
| 502 | raw, err := hex.DecodeString(parts[0]) |
| 503 | if err != nil || (len(raw) != 4 && len(raw) != 16) { |
| 504 | return nil, 0, fmt.Errorf("bad ip %q", parts[0]) |
| 505 | } |
| 506 | // The kernel prints each 32-bit word in host (little-endian) order. |
| 507 | ip := make(net.IP, len(raw)) |
| 508 | for w := 0; w < len(raw); w += 4 { |
| 509 | ip[w], ip[w+1], ip[w+2], ip[w+3] = raw[w+3], raw[w+2], raw[w+1], raw[w] |
| 510 | } |
| 511 | port, err := strconv.ParseInt(parts[1], 16, 32) |
| 512 | if err != nil { |
| 513 | return nil, 0, err |
| 514 | } |
| 515 | return ip, int(port), nil |
| 516 | } |
internal/checks/collect/collect.go · VHosts · Lines 554-575View on GitHub at This Commit
| 554 | // VHosts returns the host names configured in Apache, nginx or LiteSpeed on |
| 555 | // this machine. Only these names are ever requested, and always through |
| 556 | // 127.0.0.1, so the scan never leaves the machine. |
| 557 | func VHosts(s sys.System) []string { |
| 558 | if v, ok := vhostOnce.Load(s); ok { |
| 559 | return v.([]string) |
| 560 | } |
| 561 | set := map[string]bool{} |
| 562 | for _, target := range WebTargets(s) { |
| 563 | set[target.Name] = true |
| 564 | } |
| 565 | var out []string |
| 566 | for n := range set { |
| 567 | out = append(out, n) |
| 568 | } |
| 569 | sort.Strings(out) |
| 570 | if len(out) > 50 { |
| 571 | out = out[:50] // keep scans bounded on large shared hosts |
| 572 | } |
| 573 | vhostOnce.Store(s, out) |
| 574 | return out |
| 575 | } |
Scan Engine
Runs the selected checks in parallel with a timeout each, turns a crash in one check into an Error result instead of stopping the scan, redacts secrets, and builds the report.
- Phases follow NIST SP 800-115 minus the attack phase.
- Results are sorted by status, then severity, so the most serious findings come first.
internal/engine/engine.go · runOne · Lines 191-242View on GitHub at This Commit
| 191 | // runOne runs a single check with a timeout and turns a panic into an Error |
| 192 | // result, so one broken check can never stop the whole scan. The check runs |
| 193 | // in its own goroutine: a check that ignores its context (for example one |
| 194 | // stuck in a blocking read) is abandoned when the timeout fires instead of |
| 195 | // stalling the scan. |
| 196 | func runOne(parent context.Context, s sys.System, c *check.Check, timeout time.Duration) report.Finding { |
| 197 | f := report.FromCheck(c) |
| 198 | if parent.Err() != nil { |
| 199 | f.Results = []check.Result{{Status: check.Error, Target: c.ID, Message: "not run: the scan deadline passed or the scan was interrupted before this check started"}} |
| 200 | f.Status = string(check.Error) |
| 201 | return f |
| 202 | } |
| 203 | ctx, cancel := context.WithTimeout(parent, timeout) |
| 204 | defer cancel() |
| 205 | type outcome struct { |
| 206 | res []check.Result |
| 207 | panicked any |
| 208 | } |
| 209 | done := make(chan outcome, 1) // buffered: an abandoned check never blocks on send |
| 210 | go func() { |
| 211 | defer func() { |
| 212 | if p := recover(); p != nil { |
| 213 | if os.Getenv("OPENVAPT_DEBUG") != "" { |
| 214 | fmt.Fprintln(os.Stderr, string(debug.Stack())) |
| 215 | } |
| 216 | done <- outcome{panicked: p} |
| 217 | } |
| 218 | }() |
| 219 | done <- outcome{res: c.Run(ctx, s)} |
| 220 | }() |
| 221 | var res []check.Result |
| 222 | select { |
| 223 | case o := <-done: |
| 224 | if o.panicked != nil { |
| 225 | res = []check.Result{{Status: check.Error, Target: c.ID, Message: fmt.Sprintf("internal error: %v", o.panicked)}} |
| 226 | } else if ctx.Err() != nil { |
| 227 | res = []check.Result{{Status: check.Error, Target: c.ID, Message: "timed out after " + timeout.String()}} |
| 228 | } else { |
| 229 | res = o.res |
| 230 | } |
| 231 | case <-ctx.Done(): |
| 232 | res = []check.Result{{Status: check.Error, Target: c.ID, Message: "timed out after " + timeout.String()}} |
| 233 | } |
| 234 | for i := range res { |
| 235 | res[i].Target = redact.String(res[i].Target) |
| 236 | res[i].Message = redact.String(res[i].Message) |
| 237 | res[i].Evidence = redact.All(res[i].Evidence) |
| 238 | } |
| 239 | f.Results = res |
| 240 | f.Status = string(overall(res)) |
| 241 | return f |
| 242 | } |
internal/engine/engine.go · overall · Lines 252-264View on GitHub at This Commit
| 252 | // overall never lets a Pass hide incomplete coverage. |
| 253 | func overall(rs []check.Result) check.Status { |
| 254 | has := map[check.Status]bool{} |
| 255 | for _, r := range rs { |
| 256 | has[r.Status] = true |
| 257 | } |
| 258 | for _, s := range []check.Status{check.Fail, check.Error, check.Incomplete, check.Pass} { |
| 259 | if has[s] { |
| 260 | return s |
| 261 | } |
| 262 | } |
| 263 | return check.Skip |
| 264 | } |
Secret Redaction
Every message and evidence line passes through redaction before it is written, so reports can be shared without leaking passwords, keys, tokens or password hashes.
internal/redact/redact.go · String · Lines 247-261View on GitHub at This Commit
| 247 | // String returns s with every known secret pattern replaced. |
| 248 | func String(s string) string { |
| 249 | for _, r := range rules { |
| 250 | s = r.re.ReplaceAllString(s, r.repl) |
| 251 | } |
| 252 | s = redactKeyValue(s) |
| 253 | if len(s) > 300 { |
| 254 | cut := 300 |
| 255 | for cut > 0 && cut < len(s) && s[cut]&0xC0 == 0x80 { // do not split a UTF-8 character |
| 256 | cut-- |
| 257 | } |
| 258 | s = s[:cut] + " [truncated]" |
| 259 | } |
| 260 | return s |
| 261 | } |
Reports
Writes the report locally as self-contained HTML (prints to PDF), JSON, SARIF 2.1.0 and Markdown, with methodology and limitations in every report, and compares two reports to show what was fixed.
internal/report/report.go · Summarise · Lines 352-386View on GitHub at This Commit
| 352 | // Summarise fills in r.Summary from r.Findings. |
| 353 | func (r *Report) Summarise() { |
| 354 | var s Summary |
| 355 | for i := range r.Findings { |
| 356 | r.Findings[i].Confidence = ConfidenceFor(r.Findings[i]) |
| 357 | s.IncompleteTargets += incompleteTargets(r.Findings[i]) |
| 358 | } |
| 359 | for _, f := range r.Findings { |
| 360 | switch check.Status(f.Status) { |
| 361 | case check.Pass: |
| 362 | s.Passed++ |
| 363 | case check.Skip: |
| 364 | s.Skipped++ |
| 365 | case check.Error: |
| 366 | s.Errors++ |
| 367 | case check.Incomplete: |
| 368 | s.Incomplete++ |
| 369 | case check.Fail: |
| 370 | s.Failed++ |
| 371 | switch check.Severity(f.Severity) { |
| 372 | case check.Critical: |
| 373 | s.Critical++ |
| 374 | case check.High: |
| 375 | s.High++ |
| 376 | case check.Medium: |
| 377 | s.Medium++ |
| 378 | case check.Low: |
| 379 | s.Low++ |
| 380 | default: |
| 381 | s.Info++ |
| 382 | } |
| 383 | } |
| 384 | } |
| 385 | r.Summary = s |
| 386 | } |
internal/report/diff.go · Diff · Lines 16-39View on GitHub at This Commit
| 16 | // Diff compares an older and a newer report: what was fixed, what is new, |
| 17 | // and what is still open. This supports the "verify the fix" step. |
| 18 | func Diff(old, cur *Report) (fixed, added, open []Change) { |
| 19 | prev := map[string]Finding{} |
| 20 | for _, f := range old.Findings { |
| 21 | prev[f.ID] = f |
| 22 | } |
| 23 | for _, f := range cur.Findings { |
| 24 | p, had := prev[f.ID] |
| 25 | c := Change{ID: f.ID, Title: f.Title, Severity: f.Severity, After: f.Status} |
| 26 | if had { |
| 27 | c.Before = p.Status |
| 28 | } |
| 29 | switch { |
| 30 | case f.Status == string(check.Fail) && (!had || p.Status != string(check.Fail)): |
| 31 | added = append(added, c) |
| 32 | case f.Status == string(check.Fail): |
| 33 | open = append(open, c) |
| 34 | case had && p.Status == string(check.Fail): |
| 35 | fixed = append(fixed, c) |
| 36 | } |
| 37 | } |
| 38 | return |
| 39 | } |
Report Signing
Optional Ed25519 signature with a key generated and kept on the user's own machine, so a recipient can confirm a report was not edited. It proves integrity, never security.
internal/sign/sign.go · Sign · Lines 158-176View on GitHub at This Commit
| 158 | // Sign attaches an Integrity block to r. The digest covers the exact bytes |
| 159 | // of r.CanonicalJSON(), which report.json reproduces with the block added. |
| 160 | func Sign(r *report.Report, priv ed25519.PrivateKey) error { |
| 161 | r.Integrity = nil |
| 162 | d, err := digest(r, Format) |
| 163 | if err != nil { |
| 164 | return err |
| 165 | } |
| 166 | pub := priv.Public().(ed25519.PublicKey) |
| 167 | r.Integrity = &report.Integrity{ |
| 168 | Algorithm: "ed25519", |
| 169 | SHA256: hex.EncodeToString(d), |
| 170 | PublicKey: base64.StdEncoding.EncodeToString(pub), |
| 171 | KeyID: KeyID(pub), |
| 172 | Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, d)), |
| 173 | Format: Format, |
| 174 | } |
| 175 | return nil |
| 176 | } |
internal/sign/sign.go · Verify · Lines 224-258View on GitHub at This Commit
| 224 | // Verify checks the Integrity block of an in-memory report. If wantKeyID is |
| 225 | // not empty, the report must also be signed by that key (share your key id |
| 226 | // out of band). To check a file, use VerifyFile, which also proves the file |
| 227 | // bytes are exactly what was signed. |
| 228 | func Verify(r *report.Report, wantKeyID string) error { |
| 229 | in := r.Integrity |
| 230 | if in == nil { |
| 231 | return errors.New("report is not signed") |
| 232 | } |
| 233 | if in.Algorithm != "ed25519" { |
| 234 | return fmt.Errorf("unsupported algorithm %q", in.Algorithm) |
| 235 | } |
| 236 | pub, err := base64.StdEncoding.DecodeString(in.PublicKey) |
| 237 | if err != nil || len(pub) != ed25519.PublicKeySize { |
| 238 | return errors.New("invalid public key") |
| 239 | } |
| 240 | sig, err := base64.StdEncoding.DecodeString(in.Signature) |
| 241 | if err != nil { |
| 242 | return errors.New("invalid signature encoding") |
| 243 | } |
| 244 | d, err := digest(r, in.Format) |
| 245 | if err != nil { |
| 246 | return err |
| 247 | } |
| 248 | if hex.EncodeToString(d) != in.SHA256 { |
| 249 | return errors.New("report content was modified after signing (digest mismatch)") |
| 250 | } |
| 251 | if !ed25519.Verify(pub, d, sig) { |
| 252 | return errors.New("signature is invalid") |
| 253 | } |
| 254 | if wantKeyID != "" && KeyID(pub) != wantKeyID { |
| 255 | return fmt.Errorf("signed by key %s, expected %s", KeyID(pub), wantKeyID) |
| 256 | } |
| 257 | return nil |
| 258 | } |
Simulated Lab
Two in-memory servers, one deliberately vulnerable and one hardened. 'openvapt demo' scans them safely on any computer, and the tests require every check to fail on the first and pass on the second.
internal/engine/engine_test.go · TestEveryCheckFiresOnVulnerableLab · Lines 12-23View on GitHub at This Commit
| 12 | // Every check must fire on the vulnerable lab server... |
| 13 | func TestEveryCheckFiresOnVulnerableLab(t *testing.T) { |
| 14 | r := Run(context.Background(), lab.Vulnerable(), Options{}) |
| 15 | for _, f := range r.Findings { |
| 16 | if f.Severity == string(check.Info) { |
| 17 | continue |
| 18 | } |
| 19 | if f.Status != string(check.Fail) { |
| 20 | t.Errorf("%s (%s) should FAIL on the vulnerable lab, got %s: %+v", f.ID, f.Title, f.Status, f.Results) |
| 21 | } |
| 22 | } |
| 23 | } |
internal/engine/engine_test.go · TestNoCheckFiresOnHardenedLab · Lines 25-33View on GitHub at This Commit
| 25 | // ...and none may fire once the fixes are applied (no false positives). |
| 26 | func TestNoCheckFiresOnHardenedLab(t *testing.T) { |
| 27 | r := Run(context.Background(), lab.Hardened(), Options{}) |
| 28 | for _, f := range r.Findings { |
| 29 | if f.Status == string(check.Fail) || f.Status == string(check.Error) { |
| 30 | t.Errorf("%s (%s) should not fail on the hardened lab, got %s: %+v", f.ID, f.Title, f.Status, f.Results) |
| 31 | } |
| 32 | } |
| 33 | } |
Command Line
The user interface: scan, demo, checks, explain, diff, verify, terms. The scan asks the user to accept the terms each run (nothing is stored) and supports --dry-run to list every operation first.
cmd/openvapt/main.go · cmdScan · Lines 344-372View on GitHub at This Commit
| 344 | func cmdScan(ctx context.Context, args []string) error { |
| 345 | var f scanFlags |
| 346 | fs := scanFlagSet(&f) |
| 347 | _ = fs.Parse(args) |
| 348 | if err := f.prepare(); err != nil { |
| 349 | return err |
| 350 | } |
| 351 | |
| 352 | opts := f.engineOptions() |
| 353 | if f.dryRun { |
| 354 | fmt.Println("Dry run: nothing will be read or executed. Operations a real scan would perform:") |
| 355 | rec := &sys.Recorder{W: os.Stdout} |
| 356 | engine.Run(ctx, rec, opts) |
| 357 | fmt.Println("\nEvery operation above is read-only and limited to this machine (127.0.0.1).") |
| 358 | return nil |
| 359 | } |
| 360 | if runtime.GOOS != "linux" { |
| 361 | return errors.New("scan supports Linux only in this version; try 'openvapt demo' or 'openvapt scan --dry-run'") |
| 362 | } |
| 363 | if !f.accept && !confirmTerms(os.Stdin, os.Stdout) { |
| 364 | return errors.New("terms not accepted; nothing was scanned") |
| 365 | } |
| 366 | if os.Geteuid() != 0 && !f.quiet { |
| 367 | fmt.Println("Note: not running as root. Checks that need protected files will be skipped.") |
| 368 | } |
| 369 | fmt.Printf("Scanning this machine with %d checks (read-only, local only)...\n", len(engine.Select(opts))) |
| 370 | r := engine.Run(ctx, sys.NewLive(), opts) |
| 371 | return finish(r, f) |
| 372 | } |