Skip to content

Commit

Permalink
[bugfix] Bring back audit summary (#2820)
Browse files Browse the repository at this point in the history
* [bugfix] Bring back audit summary

This PR brings back the audit summary view and displays only that by
default. This restores the old behaviour before we refactored the
audit implementation. The new view is still available with the
new --full flag.

Fixes #2816

Signed-off-by: Dominik Schulz <[email protected]>

* Fix tests.

Signed-off-by: Dominik Schulz <[email protected]>

* Fix integration test

Signed-off-by: Dominik Schulz <[email protected]>

---------

Signed-off-by: Dominik Schulz <[email protected]>
  • Loading branch information
dominikschulz authored Mar 13, 2024
1 parent 0562045 commit 34567d9
Show file tree
Hide file tree
Showing 13 changed files with 145 additions and 22 deletions.
22 changes: 18 additions & 4 deletions internal/action/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,26 @@ func (s *Action) Audit(c *cli.Context) error {
case "csv":
return saveReport(ctx, r.RenderCSV, c.String("output-file"), "csv")
default:
if err := r.PrintResults(ctx); err != nil {
return err
var err error
if c.Bool("full") {
debug.Log("Printing full report")
err = r.PrintResults(ctx)
}
if c.Bool("summary") {
debug.Log("Printing summary")

nerr := r.PrintSummary(ctx)
// do not overwrite err if it is already set
if err == nil {
err = nerr
}
}
if !c.Bool("full") && !c.Bool("summary") {
out.Warning(ctx, "No output format specified. Use `--full` or `--summary` to specify.")
}
}

return nil
return err
}
}

func saveReport(ctx context.Context, f func(io.Writer) error, path, suffix string) error {
Expand Down
6 changes: 3 additions & 3 deletions internal/action/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,17 @@ func TestAudit(t *testing.T) {
stdout = os.Stdout
}()

t.Run("expect audit complaints on very weak passwords", func(t *testing.T) {
t.Run("expect audit to complains on very weak passwords", func(t *testing.T) {
sec := secrets.NewAKV()
sec.SetPassword("123")
require.NoError(t, act.Store.Set(ctx, "bar", sec))
require.NoError(t, act.Store.Set(ctx, "baz", sec))

require.Error(t, act.Audit(gptest.CliCtx(ctx, t)))
require.Error(t, act.Audit(gptest.CliCtxWithFlags(ctx, t, map[string]string{"full": "true"})), buf.String())
buf.Reset()
})

t.Run("test with filter and very passwords", func(t *testing.T) {
t.Run("test with filter", func(t *testing.T) {
c := gptest.CliCtx(ctx, t, "foo")
require.Error(t, act.Audit(c))
buf.Reset()
Expand Down
9 changes: 7 additions & 2 deletions internal/action/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,13 @@ func (s *Action) GetCommands() []*cli.Command {
Usage: "HTML template. If not set use the built-in default.",
},
&cli.BoolFlag{
Name: "failed",
Usage: "Report only entries that failed validation. Default: false (reports all)",
Name: "full",
Usage: "Print full details of all findings. Default: false",
},
&cli.BoolFlag{
Name: "summary",
Usage: "Print a summary of the audit results. Default: true (print summary)",
Value: true,
},
},
},
Expand Down
51 changes: 49 additions & 2 deletions internal/audit/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"os"
"sort"
"strings"
"text/template"
"time"

Expand All @@ -23,17 +24,35 @@ func (r *Report) PrintResults(ctx context.Context) error {
return nil
}

debug.Log("Printing results for %d secrets", len(r.Secrets))

var failed bool
for _, name := range set.SortedKeys(r.Secrets) {
s := r.Secrets[name]
out.Printf(ctx, "%s (age: %s)", name, s.Age.String())
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%s (age: %s) ", name, s.HumanizeAge()))
if !s.HasFindings() {
sb.WriteString("OK")
out.OK(ctx, sb.String())

continue
}
sb.WriteString("Potentially weak. ")
for k, v := range s.Findings {
if v.Severity == "error" || v.Severity == "warning" {
failed = true
}

out.Errorf(ctx, "[%s] %s: %s", v.Severity, k, v.Message)
switch v.Severity {
case "error":
fallthrough
case "warning":
sb.WriteString(fmt.Sprintf("%s: %s. ", k, v.Message))
default:
continue
}
}
out.Warning(ctx, sb.String())
}

if failed {
Expand All @@ -43,6 +62,34 @@ func (r *Report) PrintResults(ctx context.Context) error {
return nil
}

func (r *Report) PrintSummary(ctx context.Context) error {
if r == nil {
out.Warning(ctx, "Empty report")

return nil
}

debug.Log("Printing summary for %d findings", len(r.Findings))

for _, name := range set.SortedKeys(r.Findings) {
f := r.Findings[name]
if f.Len() < 1 {
continue
}
// TODO add details about the analyzer, not just the name
out.Printf(ctx, "Analyzer %s found issues: ", name)
for _, v := range set.Sorted(f.Elements()) {
out.Printf(ctx, "- %s", v)
}
}

if len(r.Findings) > 0 {
return fmt.Errorf("weak password or duplicates detected")
}

return nil
}

func (r *Report) RenderCSV(w io.Writer) error {
cw := csv.NewWriter(w)

Expand Down
57 changes: 55 additions & 2 deletions internal/audit/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/gopasspw/gopass/internal/hashsum"
"github.com/gopasspw/gopass/internal/set"
"github.com/gopasspw/gopass/pkg/debug"
)

type Finding struct {
Expand All @@ -15,11 +16,39 @@ type Finding struct {
}

type SecretReport struct {
Name string
Name string
// analyzer -> finding details
Findings map[string]Finding
Age time.Duration
}

func (s *SecretReport) HasFindings() bool {
for _, f := range s.Findings {
if f.Severity != "none" {
return true
}
}

return false
}

func (s *SecretReport) HumanizeAge() string {
if s.Age < 24*time.Hour {
return fmt.Sprintf("%d hours", int(s.Age.Hours()))
}
days := int(s.Age.Hours() / 24)
if days < 30 {
return fmt.Sprintf("%d days", days)
}
months := days / 30
if months < 12 {
return fmt.Sprintf("%d months", months)
}
years := months / 12

return fmt.Sprintf("%d years", years)
}

func errors(e []error) []string {
s := make([]string, 0, len(e))
for _, es := range e {
Expand All @@ -30,15 +59,25 @@ func errors(e []error) []string {
}

type Report struct {
Secrets map[string]SecretReport
// secret name -> report
Secrets map[string]SecretReport

// finding -> secrets
Findings map[string]set.Set[string]

Template string
Duration time.Duration
}

type ReportBuilder struct {
// protects all below
sync.Mutex

// secret name -> report
secrets map[string]SecretReport
// finding -> secrets
findings map[string]set.Set[string]

// SHA512(password) -> secret names
duplicates map[string]set.Set[string]

Expand Down Expand Up @@ -76,6 +115,7 @@ func (r *ReportBuilder) AddFinding(secret, finding, message, severity string) {
r.Lock()
defer r.Unlock()

// record individual findings
s := r.secrets[secret]
s.Name = secret
if s.Findings == nil {
Expand All @@ -86,6 +126,11 @@ func (r *ReportBuilder) AddFinding(secret, finding, message, severity string) {
f.Severity = severity
s.Findings[finding] = f
r.secrets[secret] = s

// record secrets per finding, for the summary
ss := r.findings[finding]
ss.Add(secret)
r.findings[finding] = ss
}

func (r *ReportBuilder) SetAge(name string, age time.Duration) {
Expand All @@ -105,6 +150,7 @@ func (r *ReportBuilder) SetAge(name string, age time.Duration) {
func newReport() *ReportBuilder {
return &ReportBuilder{
secrets: make(map[string]SecretReport, 512),
findings: make(map[string]set.Set[string], 512),
duplicates: make(map[string]set.Set[string], 512),
sha1sums: make(map[string]set.Set[string], 512),
t0: time.Now().UTC(),
Expand Down Expand Up @@ -134,12 +180,19 @@ func (r *ReportBuilder) Finalize() *Report {

ret := &Report{
Secrets: make(map[string]SecretReport, len(r.secrets)),
Findings: make(map[string]set.Set[string], len(r.findings)),
Duration: time.Since(r.t0),
}

for k := range r.secrets {
ret.Secrets[k] = r.secrets[k]
}

for k := range r.findings {
ret.Findings[k] = r.findings[k]
}

debug.Log("Finalized report: %d secrets, %d findings", len(ret.Secrets), len(ret.Findings))

return ret
}
2 changes: 1 addition & 1 deletion internal/backend/storage/fs/rcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func (s *Store) Revisions(context.Context, string) ([]backend.Revision, error) {
Hash: "latest",
Date: time.Now(),
},
}, nil
}, backend.ErrNotSupported
}

// GetRevision only supports getting the latest revision.
Expand Down
2 changes: 1 addition & 1 deletion internal/backend/storage/fs/rcs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func TestRCS(t *testing.T) {
assert.Equal(t, "fs", g.Name())
require.Error(t, g.AddRemote(ctx, "foo", "bar"))
revs, err := g.Revisions(ctx, "foo")
require.NoError(t, err)
require.Error(t, err)
assert.Len(t, revs, 1)
body, err := g.GetRevision(ctx, "foo", "latest")
require.Error(t, err)
Expand Down
6 changes: 5 additions & 1 deletion internal/store/leaf/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package leaf

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -95,7 +96,10 @@ func (s *Store) Convert(ctx context.Context, cryptoBe backend.CryptoBackend, sto
debug.Log("converting %s", e)
revs, err := s.ListRevisions(ctx, e)
if err != nil {
return fmt.Errorf("failed to list revision of %s: %w", e, err)
// the fs backend does not support revisions. but we can still convert the "latest" (only) revision.
if !errors.Is(err, backend.ErrNotSupported) || len(revs) < 1 {
return fmt.Errorf("failed to list revision of %s: %w", e, err)
}
}
sort.Sort(sort.Reverse(backend.Revisions(revs)))

Expand Down
4 changes: 2 additions & 2 deletions internal/store/leaf/rcs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ func TestGitRevisions(t *testing.T) {
require.NoError(t, s.Storage().InitConfig(ctx, "foo", "[email protected]"))

revs, err := s.ListRevisions(ctx, "foo")
require.NoError(t, err)
assert.Len(t, revs, 1)
require.Error(t, err) // not supported by the fs backend
assert.Len(t, revs, 1) // but it will still give a fake "latest" rev

sec, err := s.GetRevision(ctx, "foo", "latest")
require.Error(t, err)
Expand Down
2 changes: 1 addition & 1 deletion internal/store/root/rcs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ func TestRCS(t *testing.T) {
require.Error(t, rs.RCSStatus(ctx, ""))

revs, err := rs.ListRevisions(ctx, "foo")
require.NoError(t, err)
require.Error(t, err)
assert.Len(t, revs, 1)
}
1 change: 0 additions & 1 deletion main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ var commandsWithError = set.Map([]string{
".alias.add",
".alias.remove",
".alias.delete",
".audit",
".cat",
".clone",
".copy",
Expand Down
3 changes: 2 additions & 1 deletion tests/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ func TestAudit(t *testing.T) {
t.Run("audit the test store", func(t *testing.T) {
out, err := ts.run("audit")
require.Error(t, err)
assert.Contains(t, out, "weak password")
assert.Contains(t, out, "crunchy")
assert.Contains(t, out, "zxcvbn")
})
}
2 changes: 1 addition & 1 deletion zsh.completion
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ _gopass () {

;;
audit)
_arguments : "--format[Output format. text, csv or html. Default: text]" "--output-file[Output filename. Used for csv and html]" "--template[HTML template. If not set use the built-in default.]" "--failed[Report only entries that failed validation. Default: false (reports all)]"
_arguments : "--format[Output format. text, csv or html. Default: text]" "--output-file[Output filename. Used for csv and html]" "--template[HTML template. If not set use the built-in default.]" "--full[Print full details of all findings. Default: false]" "--summary[Print a summary of the audit results. Default: true (print summary)]"


;;
Expand Down

0 comments on commit 34567d9

Please sign in to comment.