Skip to Content
OpenVAPTAlpha
Pre-release. Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. Free for your own use, 100% at your own risk. This website collects no data. Read MoreRead Less
Pre-release. OpenVAPT has no Stable release yet. Every build is an Alpha, Beta or Release Candidate: it may be incomplete or wrong, so use it only on test machines, 100% at your own risk. See Release Channels
Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. These are AI systems, not people or registered engineers. Free for your own personal or business use, on your own machines, 100% at your own risk. Not for sale. Not for providing services. This website collects no data. Read the Acknowledgements
Pre-release. OpenVAPT has no Stable release yet. Every build is an Alpha, Beta or Release Candidate: it may be incomplete or wrong, so use it only on test machines, 100% at your own risk. See Release Channels
Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. These are AI systems, not people or registered engineers. Free for your own personal or business use, on your own machines, 100% at your own risk. Not for sale. Not for providing services. This website collects no data. Read the Acknowledgements

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.

you@server: ~
$ curl -fsSL https://raw.githubusercontent.com/OpenVAPT/openvapt/main/scripts/install.sh | sh
OpenVAPT installer
[while there is no Stable release, two pre-release WARNING notes are printed around this line, trimmed]
release vX.Y.Z, linux/amd64
downloading openvapt_linux_amd64 and SHA256SUMS from https://github.com/OpenVAPT/openvapt/releases/download/vX.Y.Z/
checksum verified (sha256 64 hex digits)
note: cosign is not installed, so the Sigstore signature was not checked.
installed /home/you/.local/bin/openvapt
 
Installed (Download Only). Nothing has been scanned. OpenVAPT checks only this machine,
read-only, and before every scan it shows the acknowledgements and waits for you to type I ACCEPT.
you@server: ~
$ sudo openvapt scan
OpenVAPT - Terms of Use and Acknowledgements, version 1.8 (2026-10-14)
Full text: https://openvapt.com/legal/eula.html
[the eight statements of what OpenVAPT is are trimmed here]
By continuing you confirm each statement below:
1. I have read, studied and understood the End User Licence Agreement, the Disclaimer, the Acceptable Use Policy, the Licence, the Teaching With OpenVAPT terms and Citing OpenVAPT.
[statements 2 to 9 are trimmed here; openvapt terms prints all of them]
10. I am at least 18 years old (or the age of majority where I live), or my parent or guardian has read these statements and accepts them for me in their own name; and where I act for an organisation, I am authorised to bind it.
 
If you have read, studied, understood, acknowledged, agreed to and accepted all of the above,
type I ACCEPT to continue (anything else cancels; nothing is stored): I ACCEPT
Scanning this machine with 307 checks (read-only, local only)...
Illustration: Checks by Domain

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

you@server: ~
$ openvapt demo --format html,md --no-sign
Demo: scanning the simulated "vulnerable" lab server (in memory, your machine is not touched).
 
Checks run: 307 Passed: 2 Failed: 305 Incomplete: 0 (plus 3 targets) Skipped: 0 Errors: 0
Failed by severity: Critical 14 High 71 Medium 88 Low 123
 
CRITICAL HOST-CTR-001 Docker API Exposed Over TCP Without TLS
CRITICAL HOST-SSH-003 SSH Permits Empty Passwords
CRITICAL NET-DB-002 Redis Authentication and Network Binding
HIGH HOST-SSH-001 SSH Allows Direct Root Login
[the other 301 failed checks are listed here, trimmed]
Reports (written only on this machine, readable only by this user):
/home/you/openvapt-demo-report-20261014-074600-3873905855/report.html
/home/you/openvapt-demo-report-20261014-074600-3873905855/report.md
Open the HTML report in any web browser, for example: xdg-open /home/you/openvapt-demo-report-20261014-074600-3873905855/report.html
openvapt: 85 finding(s) at or above high severity
$ echo $?
3
file:///home/you/openvapt-demo-report-20261014-074600-3873905855/report.html

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.

14Critical71High88Medium123Low2Passed

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.

you@server: ~
$ openvapt explain HOST-SSH-001
HOST-SSH-001 SSH Allows Direct Root Login
Domain: host Severity: high CVSS: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
What it checks:
Checks whether the SSH daemon accepts logins as root with a password.
[why it matters, how it detects, how to fix and references are trimmed here]
How to verify:
sshd -T | grep -i permitrootlogin should print 'permitrootlogin no'.
- P2: SSH Security (Practitioner, about 15 minutes)
offline: openvapt learn P2
$ openvapt learn
OpenVAPT Academy: learn vulnerability assessment from theory to application.
Free, offline, nothing stored. Also online at https://openvapt.com/academy/
Foundations
F1 Security Basics 15 min
F2 What VAPT Is, and Doing It Legally 20 min
F3 Vulnerabilities, CVE, CWE and CVSS 20 min
[22 Practitioner and 6 Applied modules are listed here, trimmed]
Read a module: openvapt learn F1
Take its quiz: openvapt learn F1 --quiz

Read It. Run It on Your Own Machine.

Free for your own use, on machines you own or control, 100% at your own risk.

Download Start the Academy

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.

  1. 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/bin without 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.
  2. Accept. Run sudo openvapt scan. OpenVAPT prints the terms and ten numbered acknowledgements, then asks you to type I ACCEPT; anything else cancels and nothing is stored. It then prints "Scanning this machine with 307 checks (read-only, local only)...".
  3. 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.
  4. Summary. openvapt demo scans 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.
  5. 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 (set PermitRootLogin no, then sshd -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.
  6. Explain and Learn. openvapt explain HOST-SSH-001 prints what the check does, why it matters, how it detects, how to fix and verify it, and its references. openvapt learn lists the offline Academy: 3 Foundations, 22 Practitioner and 6 Applied modules, each with a quiz.
  7. 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.

internal/scope

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.
23func 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.
29func 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.
48func (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}

internal/sys

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.
126func 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
201func 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.
209func 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}

internal/check

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.
129func 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}

internal/checks/collect

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).
497func 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.
557func 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}

internal/engine

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.
196func 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.
253func 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}

internal/redact

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.
248func 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}

internal/report

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.
353func (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.
18func 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}

internal/sign

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.
160func 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.
228func 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}

internal/lab

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...
13func 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).
26func 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}

cmd/openvapt

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
344func 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}