1
0
mirror of https://github.com/strongdm/comply synced 2025-12-06 14:24:12 +00:00

Initial commit

This commit is contained in:
Justin McCarthy
2018-05-09 18:02:33 -07:00
commit bd7899ee31
1036 changed files with 263439 additions and 0 deletions

116
internal/cli/app.go Normal file
View File

@@ -0,0 +1,116 @@
package cli
import (
"context"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"path/filepath"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/strongdm/comply/internal/config"
"github.com/strongdm/comply/internal/plugin/github"
"github.com/urfave/cli"
)
// Version is set by the build system.
const Version = "0.0.0-development"
// Main should be invoked by the main function in the main package.
func Main() {
err := newApp().Run(os.Args)
if err != nil {
log.Fatal(err)
}
}
func newApp() *cli.App {
app := cli.NewApp()
app.Name = "comply"
app.HideVersion = true
app.Version = Version
app.Usage = "policy compliance toolkit"
app.Commands = []cli.Command{
initCommand,
}
app.Commands = append(app.Commands, beforeCommand(buildCommand, projectMustExist))
app.Commands = append(app.Commands, beforeCommand(schedulerCommand, projectMustExist))
app.Commands = append(app.Commands, beforeCommand(serveCommand, projectMustExist))
app.Commands = append(app.Commands, beforeCommand(syncCommand, projectMustExist))
app.Commands = append(app.Commands, beforeCommand(todoCommand, projectMustExist))
// Plugins
github.Register()
return app
}
func beforeCommand(c cli.Command, bf ...cli.BeforeFunc) cli.Command {
c.Before = beforeAll(bf...)
return c
}
func beforeAll(bf ...cli.BeforeFunc) cli.BeforeFunc {
return func(c *cli.Context) error {
for _, f := range bf {
if err := f(c); err != nil {
return err
}
}
return nil
}
}
func feedbackError(message string) error {
return errors.New(fmt.Sprintf("\n\nERROR\n=====\n%s\n", message))
}
func projectMustExist(c *cli.Context) error {
_, err := ioutil.ReadFile(filepath.Join(config.ProjectRoot(), "comply.yml"))
if err != nil {
return feedbackError("command must be run from the root of a valid comply project (comply.yml must exist; have you run `comply init`?)")
}
return nil
}
func dockerMustExist(c *cli.Context) error {
dockerErr := fmt.Errorf("Docker must be available in order to run `%s`", c.Command.Name)
ctx := context.Background()
cli, err := client.NewEnvClient()
if err != nil {
return dockerErr
}
r, err := cli.ImagePull(ctx, "strongdm/pandoc:latest", types.ImagePullOptions{})
if err != nil {
return dockerErr
}
defer r.Close()
done := make(chan struct{})
defer close(done)
go func() {
// if docker IO takes more than N seconds, notify user we're (likely) downloading the pandoc image
longishPull := time.After(time.Second * 6)
select {
case <-longishPull:
fmt.Println("Downloading strongdm/pandoc image (this may take sometime) ...")
case <-done:
// in this case, the docker pull was quick -- suggesting we already have the container
}
}()
// hold function open until all docker IO is complete
io.Copy(ioutil.Discard, r)
return nil
}

29
internal/cli/build.go Normal file
View File

@@ -0,0 +1,29 @@
package cli
import (
"github.com/pkg/errors"
"github.com/strongdm/comply/internal/render"
"github.com/urfave/cli"
)
var buildCommand = cli.Command{
Name: "build",
ShortName: "b",
Usage: "generate a static website summarizing the compliance program",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "live, l",
Usage: "rebuild static site after filesystem changes",
},
},
Action: buildAction,
Before: beforeAll(dockerMustExist),
}
func buildAction(c *cli.Context) error {
err := render.Build("output", false)
if err != nil {
return errors.Wrap(err, "build failed")
}
return nil
}

4
internal/cli/doc.go Normal file
View File

@@ -0,0 +1,4 @@
/*
Package cli defines comply commands and arguments.
*/
package cli

153
internal/cli/init.go Normal file
View File

@@ -0,0 +1,153 @@
package cli
import (
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/manifoldco/promptui"
"github.com/strongdm/comply/internal/config"
"github.com/strongdm/comply/internal/model"
"github.com/strongdm/comply/internal/theme"
"github.com/urfave/cli"
"gopkg.in/yaml.v2"
)
var initCommand = cli.Command{
Name: "init",
Usage: "initialize a new compliance repository (interactive)",
Action: initAction,
}
func initAction(c *cli.Context) error {
fi, _ := ioutil.ReadDir(config.ProjectRoot())
if len(fi) > 0 {
return errors.New("init must be run from an empty directory")
}
atLeast := func(n int) func(string) error {
return func(input string) error {
if len(input) < n {
return errors.New("Too short")
}
return nil
}
}
noSpaces := func(s string) error {
if strings.ContainsAny(s, "\n\t ") {
return errors.New("Must not contain spaces")
}
return nil
}
prompt := promptui.Prompt{
Label: "Organization Name",
Validate: atLeast(1),
}
name, err := prompt.Run()
if err != nil {
fmt.Printf("Prompt failed %v\n", err)
return err
}
prompt = promptui.Prompt{
Label: "PDF Filename Prefix",
Default: strings.Split(name, " ")[0],
Validate: noSpaces,
}
prefix, err := prompt.Run()
if err != nil {
fmt.Printf("Prompt failed %v\n", err)
return err
}
chooser := promptui.Select{
Label: "Compliance Templates",
Items: []string{"SOC2", "Blank"},
}
choice, _, err := chooser.Run()
if err != nil {
fmt.Printf("Prompt failed %v\n", err)
return err
}
themeName := "comply-blank"
switch choice {
case 0:
themeName = "comply-soc2"
case 1:
themeName = "comply-blank"
default:
panic("unrecognized selection")
}
chooser = promptui.Select{
Label: "Ticket System",
Items: []string{"Github", "JIRA"},
}
choice, _, err = chooser.Run()
if err != nil {
fmt.Printf("Prompt failed %v\n", err)
return err
}
ticketing := model.Github
switch choice {
case 0:
ticketing = model.Github
case 1:
ticketing = model.JIRA
default:
panic("unrecognized selection")
}
ticketConfig := make(map[string]string)
plugin := model.GetPlugin(ticketing)
ticketPrompts := plugin.Prompts()
for k, prompt := range ticketPrompts {
p := promptui.Prompt{
Label: prompt,
Validate: atLeast(2),
}
v, err := p.Run()
if err != nil {
fmt.Printf("Prompt failed: %v\n", err)
return err
}
ticketConfig[k] = v
}
p := config.Project{}
p.Name = name
p.FilePrefix = prefix
p.Tickets = make(map[string]interface{})
p.Tickets[string(ticketing)] = ticketConfig
x, _ := yaml.Marshal(&p)
err = ioutil.WriteFile(filepath.Join(config.ProjectRoot(), "comply.yml"), x, os.FileMode(0644))
if err != nil {
return cli.NewExitError(err, 1)
}
err = theme.SaveTo(themeName, config.ProjectRoot())
if err != nil {
return cli.NewExitError(err, 1)
}
success := fmt.Sprintf("%s Compliance initialized successfully", name)
fmt.Println(strings.Repeat("=", len(success)+2))
fmt.Printf("%s %s\n", promptui.IconGood, success)
return nil
}

21
internal/cli/scheduler.go Normal file
View File

@@ -0,0 +1,21 @@
package cli
import (
"github.com/strongdm/comply/internal/ticket"
"github.com/urfave/cli"
)
var schedulerCommand = cli.Command{
Name: "scheduler",
Usage: "create tickets based on procedure schedule",
Action: schedulerAction,
Before: projectMustExist,
}
func schedulerAction(c *cli.Context) error {
err := syncAction(c)
if err != nil {
return err
}
return ticket.TriggerScheduled()
}

21
internal/cli/serve.go Normal file
View File

@@ -0,0 +1,21 @@
package cli
import (
"github.com/strongdm/comply/internal/render"
"github.com/urfave/cli"
)
var serveCommand = cli.Command{
Name: "serve",
Usage: "live updating version of the build command",
Action: serveAction,
Before: beforeAll(dockerMustExist),
}
func serveAction(c *cli.Context) error {
err := render.Build("output", true)
if err != nil {
return err
}
return nil
}

28
internal/cli/sync.go Normal file
View File

@@ -0,0 +1,28 @@
package cli
import (
"github.com/strongdm/comply/internal/model"
"github.com/urfave/cli"
)
var syncCommand = cli.Command{
Name: "sync",
Usage: "sync ticket status to local cache",
Action: syncAction,
Before: projectMustExist,
}
func syncAction(c *cli.Context) error {
tp := model.GetPlugin(model.Github)
tickets, err := tp.FindByTagName("comply")
if err != nil {
return err
}
for _, t := range tickets {
err = model.DB().Write("tickets", t.ID, t)
if err != nil {
return err
}
}
return nil
}

68
internal/cli/todo.go Normal file
View File

@@ -0,0 +1,68 @@
package cli
import (
"os"
"sort"
"github.com/fatih/color"
"github.com/olekukonko/tablewriter"
"github.com/strongdm/comply/internal/model"
"github.com/urfave/cli"
)
var todoCommand = cli.Command{
Name: "todo",
Usage: "list declared vs satisfied compliance controls",
Action: todoAction,
Before: projectMustExist,
}
func todoAction(c *cli.Context) error {
d, err := model.ReadData()
if err != nil {
return err
}
w := tablewriter.NewWriter(os.Stdout)
w.SetHeader([]string{"Standard", "Control", "Satisfied?", "Name"})
type row struct {
standard string
controlKey string
satisfied string
controlName string
}
satisfied := model.ControlsSatisfied(d)
var rows []row
for _, std := range d.Standards {
for id, c := range std.Controls {
sat := "NO"
if _, ok := satisfied[id]; ok {
sat = color.GreenString("YES")
}
rows = append(rows, row{
standard: std.Name,
controlKey: id,
satisfied: sat,
controlName: c.Name,
})
}
}
sort.Slice(rows, func(i, j int) bool {
return rows[i].controlKey < rows[j].controlKey
})
w.SetAutoWrapText(false)
for _, r := range rows {
w.Append([]string{r.standard, r.controlKey, r.satisfied, r.controlName})
}
w.Render()
return nil
}

66
internal/config/config.go Normal file
View File

@@ -0,0 +1,66 @@
package config
import (
"io/ioutil"
"os"
"path/filepath"
"gopkg.in/yaml.v2"
)
var projectRoot string
// SetProjectRoot is used by the test suite.
func SetProjectRoot(dir string) {
projectRoot = dir
}
type Project struct {
Name string `yaml:"name"`
FilePrefix string `yaml:"filePrefix"`
Tickets map[string]interface{} `yaml:"tickets"`
}
// YAML is the parsed contents of ProjectRoot()/config.yml.
func YAML() map[interface{}]interface{} {
m := make(map[interface{}]interface{})
cfgBytes, err := ioutil.ReadFile(filepath.Join(ProjectRoot(), "comply.yml"))
if err != nil {
panic("unable to load config.yml: " + err.Error())
}
yaml.Unmarshal(cfgBytes, &m)
return m
}
// Exists tests for the presence of a comply configuration file.
func Exists() bool {
_, err := ioutil.ReadFile(filepath.Join(ProjectRoot(), "comply.yml"))
if err != nil {
return false
}
return true
}
// Config is the parsed contents of ProjectRoot()/config.yml.
func Config() Project {
p := Project{}
cfgBytes, err := ioutil.ReadFile(filepath.Join(ProjectRoot(), "comply.yml"))
if err != nil {
panic("unable to load config.yml: " + err.Error())
}
yaml.Unmarshal(cfgBytes, &p)
return p
}
// ProjectRoot is the fully-qualified path to the root directory.
func ProjectRoot() string {
if projectRoot == "" {
dir, err := os.Getwd()
if err != nil {
panic(err)
}
projectRoot = dir
}
return projectRoot
}

4
internal/config/doc.go Normal file
View File

@@ -0,0 +1,4 @@
/*
Package config provides access to the comply.yml file.
*/
package config

6
internal/model/audit.go Normal file
View File

@@ -0,0 +1,6 @@
package model
type Audit struct {
ID string
Name string
}

36
internal/model/db.go Normal file
View File

@@ -0,0 +1,36 @@
package model
import (
"os"
"path/filepath"
"sync"
"github.com/nanobox-io/golang-scribble"
"github.com/strongdm/comply/internal/config"
)
var dbSingletonOnce sync.Once
var dbSingleton *scribble.Driver
// DB provides a singleton reference to a local json cache; will panic if storage location is not writeable.
func DB() *scribble.Driver {
dbSingletonOnce.Do(func() {
if _, err := os.Stat(filepath.Join(config.ProjectRoot(), ".comply", "cache")); os.IsNotExist(err) {
err = os.Mkdir(filepath.Join(config.ProjectRoot(), ".comply"), os.FileMode(0755))
if err != nil {
panic("could not create directory .comply: " + err.Error())
}
err = os.Mkdir(filepath.Join(config.ProjectRoot(), ".comply", "cache"), os.FileMode(0755))
if err != nil {
panic("could not create directory .comply/cache: " + err.Error())
}
}
db, err := scribble.New(filepath.Join(config.ProjectRoot(), ".comply", "cache"), nil)
if err != nil {
panic("unable to load comply data: " + err.Error())
}
dbSingleton = db
})
return dbSingleton
}

35
internal/model/db_test.go Normal file
View File

@@ -0,0 +1,35 @@
package model
import (
"os"
"path/filepath"
"testing"
"github.com/strongdm/comply/internal/config"
)
func TestSaveGet(t *testing.T) {
dir := os.TempDir()
config.SetProjectRoot(dir)
f, err := os.Create(filepath.Join(dir, "config.yml"))
if err != nil {
panic(err)
}
f.Close()
name := "Do something excellent"
err = DB().Write("tickets", "100", &Ticket{ID: "100", Name: name})
if err != nil {
panic(err)
}
ticket := &Ticket{}
err = DB().Read("tickets", "100", ticket)
if err != nil {
panic(err)
}
if ticket.Name != name {
t.Error("failed to read ticket")
}
}

14
internal/model/doc.go Normal file
View File

@@ -0,0 +1,14 @@
/*
Package model defines the comply data model.
Markdown Wrappers
The model package treats typed markdown files as model objects. All wrapped markdown documents are assumed to have a YAML header and a markdown body separated by three dashes: "---".
Local Ticket Cache
Tickets are defined externally (in the configured ticketing system), and cached locally for rapid dashboard rendering.
*/
package model

188
internal/model/fs.go Normal file
View File

@@ -0,0 +1,188 @@
package model
import (
"encoding/json"
"fmt"
"io/ioutil"
"strings"
"github.com/pkg/errors"
"github.com/strongdm/comply/internal/config"
"github.com/strongdm/comply/internal/path"
"gopkg.in/yaml.v2"
)
// ReadData loads all records from both the filesystem and ticket cache.
func ReadData() (*Data, error) {
tickets, err := ReadTickets()
if err != nil {
return nil, err
}
narratives, err := ReadNarratives()
if err != nil {
return nil, err
}
policies, err := ReadPolicies()
if err != nil {
return nil, err
}
procedures, err := ReadProcedures()
if err != nil {
return nil, err
}
standards, err := ReadStandards()
if err != nil {
return nil, err
}
return &Data{
Tickets: tickets,
Narratives: narratives,
Policies: policies,
Procedures: procedures,
Standards: standards,
}, nil
}
// ReadTickets returns all known tickets, or an empty list in the event the ticket cache is empty or unavailable.
func ReadTickets() ([]*Ticket, error) {
rt, err := DB().ReadAll("tickets")
if err != nil {
// empty list
return []*Ticket{}, nil
}
return tickets(rt)
}
func tickets(rawTickets []string) ([]*Ticket, error) {
var tickets []*Ticket
for _, rt := range rawTickets {
t := &Ticket{}
err := json.Unmarshal([]byte(rt), t)
if err != nil {
return nil, errors.Wrap(err, "malformed ticket JSON")
}
tickets = append(tickets, t)
}
return tickets, nil
}
// ReadStandards loads standard definitions from the filesystem.
func ReadStandards() ([]*Standard, error) {
var standards []*Standard
files, err := path.Standards()
if err != nil {
return nil, errors.Wrap(err, "unable to enumerate paths")
}
for _, f := range files {
s := &Standard{}
sBytes, err := ioutil.ReadFile(f.FullPath)
if err != nil {
return nil, errors.Wrap(err, "unable to read "+f.FullPath)
}
yaml.Unmarshal(sBytes, &s)
standards = append(standards, s)
}
return standards, nil
}
// ReadNarratives loads narrative descriptions from the filesystem.
func ReadNarratives() ([]*Narrative, error) {
var narratives []*Narrative
files, err := path.Narratives()
if err != nil {
return nil, errors.Wrap(err, "unable to enumerate paths")
}
for _, f := range files {
n := &Narrative{}
mdmd := loadMDMD(f.FullPath)
err = yaml.Unmarshal([]byte(mdmd.yaml), &n)
if err != nil {
return nil, errors.Wrap(err, "unable to parse "+f.FullPath)
}
n.Body = mdmd.body
n.FullPath = f.FullPath
n.ModifiedAt = f.Info.ModTime()
n.OutputFilename = fmt.Sprintf("%s-%s.pdf", config.Config().FilePrefix, n.Acronym)
narratives = append(narratives, n)
}
return narratives, nil
}
// ReadProcedures loads procedure descriptions from the filesystem.
func ReadProcedures() ([]*Procedure, error) {
var procedures []*Procedure
files, err := path.Procedures()
if err != nil {
return nil, errors.Wrap(err, "unable to enumerate paths")
}
for _, f := range files {
p := &Procedure{}
mdmd := loadMDMD(f.FullPath)
err = yaml.Unmarshal([]byte(mdmd.yaml), &p)
if err != nil {
return nil, errors.Wrap(err, "unable to parse "+f.FullPath)
}
p.Body = mdmd.body
p.FullPath = f.FullPath
p.ModifiedAt = f.Info.ModTime()
procedures = append(procedures, p)
}
return procedures, nil
}
// ReadPolicies loads policy documents from the filesystem.
func ReadPolicies() ([]*Policy, error) {
var policies []*Policy
files, err := path.Policies()
if err != nil {
return nil, errors.Wrap(err, "unable to enumerate paths")
}
for _, f := range files {
p := &Policy{}
mdmd := loadMDMD(f.FullPath)
err = yaml.Unmarshal([]byte(mdmd.yaml), &p)
if err != nil {
return nil, errors.Wrap(err, "unable to parse "+f.FullPath)
}
p.Body = mdmd.body
p.FullPath = f.FullPath
p.ModifiedAt = f.Info.ModTime()
p.OutputFilename = fmt.Sprintf("%s-%s.pdf", config.Config().FilePrefix, p.Acronym)
policies = append(policies, p)
}
return policies, nil
}
type metadataMarkdown struct {
yaml string
body string
}
func loadMDMD(path string) metadataMarkdown {
bytes, err := ioutil.ReadFile(path)
if err != nil {
panic(err)
}
content := string(bytes)
components := strings.Split(content, "---")
if len(components) == 1 {
panic(fmt.Sprintf("Malformed metadata markdown in %s, must be of the form: YAML\\n---\\nmarkdown content", path))
}
yaml := components[0]
body := strings.Join(components[1:], "---")
return metadataMarkdown{yaml, body}
}

17
internal/model/model.go Normal file
View File

@@ -0,0 +1,17 @@
package model
type Data struct {
Standards []*Standard
Narratives []*Narrative
Policies []*Policy
Procedures []*Procedure
Tickets []*Ticket
Audits []*Audit
}
type Revision struct {
Date string `yaml:"date"`
Comment string `yaml:"comment"`
}
type Satisfaction map[string][]string

View File

@@ -0,0 +1,44 @@
package model
import (
"encoding/json"
"strings"
"testing"
)
func TestMarshal(t *testing.T) {
d := Data{
Tickets: []*Ticket{
&Ticket{
ID: "t1",
},
},
Audits: []*Audit{
&Audit{
ID: "a1",
},
},
Procedures: []*Procedure{
&Procedure{
Code: "pro1",
},
},
Policies: []*Policy{
&Policy{
Name: "pol1",
},
},
}
m, _ := json.Marshal(d)
encoded := string(m)
if !strings.Contains(encoded, "t1") ||
!strings.Contains(encoded, "a1") ||
!strings.Contains(encoded, "pro1") ||
!strings.Contains(encoded, "pol1") {
t.Error("identifier not found in marshalled string")
}
}

View File

@@ -0,0 +1,15 @@
package model
import "time"
type Narrative struct {
Name string `yaml:"name"`
Acronym string `yaml:"acronym"`
Revisions []Revision `yaml:"majorRevisions"`
Satisfies Satisfaction `yaml:"satisfies"`
FullPath string
OutputFilename string
ModifiedAt time.Time
Body string
}

89
internal/model/plugin.go Normal file
View File

@@ -0,0 +1,89 @@
package model
import (
"fmt"
"sync"
"github.com/davecgh/go-spew/spew"
"github.com/strongdm/comply/internal/config"
)
var tsPluginsMu sync.Mutex
var tsPlugins = make(map[TicketSystem]TicketPlugin)
var tsConfigureOnce sync.Once
// TicketSystem is the type of ticket database.
type TicketSystem string
const (
// JIRA from Atlassian.
JIRA = TicketSystem("jira")
// Github from Github.
Github = TicketSystem("github")
)
// TicketPlugin models support for ticketing systems.
type TicketPlugin interface {
Get(ID string) (*Ticket, error)
FindOpen() ([]*Ticket, error)
FindByTag(name, value string) ([]*Ticket, error)
FindByTagName(name string) ([]*Ticket, error)
Create(ticket *Ticket, labels []string) error
Configure(map[string]interface{}) error
Prompts() map[string]string
}
// GetPlugin loads the ticketing database.
func GetPlugin(ts TicketSystem) TicketPlugin {
tsPluginsMu.Lock()
defer tsPluginsMu.Unlock()
tp, ok := tsPlugins[ts]
if !ok {
panic("Unknown ticket system: " + ts)
}
if config.Exists() {
tsConfigureOnce.Do(func() {
ticketsMap := config.Config().Tickets
cfg, ok := ticketsMap[string(ts)]
if !ok {
spew.Dump(cfg)
panic(fmt.Sprintf("Missing configuration for plugin system; add `%s` block to project YAML", string(ts)))
}
cfgTyped, ok := cfg.(map[interface{}]interface{})
if !ok {
spew.Dump(cfg)
panic(fmt.Sprintf("malformatted ticket configuration block `%s` in project YAML", string(ts)))
}
cfgStringed := make(map[string]interface{})
for k, v := range cfgTyped {
kS, ok := k.(string)
if !ok {
spew.Dump(cfgStringed)
panic(fmt.Sprintf("malformatted key in configuration block `%s` in project YAML", string(ts)))
}
cfgStringed[kS] = v
}
tp.Configure(cfgStringed)
})
}
return tp
}
// Register ticketing system plugin.
func Register(ts TicketSystem, plugin TicketPlugin) {
tsPluginsMu.Lock()
defer tsPluginsMu.Unlock()
_, ok := tsPlugins[ts]
if ok {
panic("Duplicate ticketing system registration: " + ts)
}
tsPlugins[ts] = plugin
}

15
internal/model/policy.go Normal file
View File

@@ -0,0 +1,15 @@
package model
import "time"
type Policy struct {
Name string `yaml:"name"`
Acronym string `yaml:"acronym"`
Revisions []Revision `yaml:"majorRevisions"`
Satisfies Satisfaction `yaml:"satisfies"`
FullPath string
OutputFilename string
ModifiedAt time.Time
Body string
}

View File

@@ -0,0 +1,16 @@
package model
import "time"
type Procedure struct {
Name string `yaml:"name"`
ID string `yaml:"id"`
Cron string `yaml:"cron"`
Revisions []Revision `yaml:"majorRevisions"`
Satisfies Satisfaction `yaml:"satisfies"`
FullPath string
OutputFilename string
ModifiedAt time.Time
Body string
}

View File

@@ -0,0 +1,49 @@
package model
type Control struct {
Family string `yaml:"family"`
Name string `yaml:"name"`
Description string `yaml:"description"`
}
type Standard struct {
Name string `yaml:"name"`
Controls map[string]Control `yaml:",inline"`
}
// ControlsSatisfied determines the unique controls currently satisfied by all Narratives, Policies, and Procedures
func ControlsSatisfied(data *Data) map[string][]string {
satisfied := make(map[string][]string)
appendSatisfaction := func(in map[string][]string, k string, v string) []string {
s, ok := in[k]
if !ok {
s = make([]string, 0)
}
s = append(s, v)
return s
}
for _, n := range data.Narratives {
for _, controlKeys := range n.Satisfies {
for _, key := range controlKeys {
satisfied[key] = appendSatisfaction(satisfied, key, n.OutputFilename)
}
}
}
for _, n := range data.Policies {
for _, controlKeys := range n.Satisfies {
for _, key := range controlKeys {
satisfied[key] = appendSatisfaction(satisfied, key, n.OutputFilename)
}
}
}
for _, n := range data.Procedures {
for _, controlKeys := range n.Satisfies {
for _, key := range controlKeys {
satisfied[key] = appendSatisfaction(satisfied, key, n.OutputFilename)
}
}
}
return satisfied
}

65
internal/model/ticket.go Normal file
View File

@@ -0,0 +1,65 @@
package model
import (
"strings"
"time"
)
type TicketState string
const (
Open = TicketState("open")
Closed = TicketState("closed")
)
type Ticket struct {
ID string
Name string
State TicketState
Body string
Attributes map[string]interface{}
ClosedAt *time.Time
CreatedAt *time.Time
UpdatedAt *time.Time
}
func (t *Ticket) ProcedureID() string {
md := t.metadata()
if v, ok := md["Procedure-ID"]; ok {
return v
}
return ""
}
func (t *Ticket) metadata() map[string]string {
md := make(map[string]string)
lines := strings.Split(t.Body, "\n")
for _, line := range lines {
// TODO: transition to RFC822 parsing
if strings.Contains(line, ":") {
tokens := strings.Split(line, ":")
if len(tokens) != 2 {
continue
}
md[strings.TrimSpace(tokens[0])] = strings.TrimSpace(tokens[1])
}
}
return md
}
func (t *Ticket) SetBool(name string) {
t.Attributes[name] = true
}
func (t *Ticket) Bool(name string) bool {
bi, ok := t.Attributes[name]
if !ok {
return false
}
b, ok := bi.(bool)
if !ok {
return false
}
return b
}

4
internal/path/doc.go Normal file
View File

@@ -0,0 +1,4 @@
/*
Package path provides convenient access to comply project path conventions.
*/
package path

55
internal/path/path.go Normal file
View File

@@ -0,0 +1,55 @@
package path
import (
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/pkg/errors"
)
// File wraps an os.FileInfo as well as the absolute path to the underlying file.
type File struct {
FullPath string
Info os.FileInfo
}
// Standards lists all standard files.
func Standards() ([]File, error) {
return filesFor("standards", "yml")
}
// Narratives lists all narrative files.
func Narratives() ([]File, error) {
return filesFor("narratives", "md")
}
// Policies lists all policy files.
func Policies() ([]File, error) {
return filesFor("policies", "md")
}
// Procedures lists all procedure files.
func Procedures() ([]File, error) {
return filesFor("procedures", "md")
}
func filesFor(name, extension string) ([]File, error) {
var filtered []File
files, err := ioutil.ReadDir(filepath.Join(".", name))
if err != nil {
return nil, errors.Wrap(err, "unable to load files for: "+name)
}
for _, f := range files {
if !strings.HasSuffix(f.Name(), "."+extension) || strings.HasPrefix(strings.ToUpper(f.Name()), "README") {
continue
}
abs, err := filepath.Abs(filepath.Join(".", name, f.Name()))
if err != nil {
return nil, errors.Wrap(err, "unable to load file: "+f.Name())
}
filtered = append(filtered, File{abs, f})
}
return filtered, nil
}

View File

@@ -0,0 +1,179 @@
package github
import (
"context"
"fmt"
"strconv"
"sync"
"github.com/google/go-github/github"
"github.com/pkg/errors"
"github.com/strongdm/comply/internal/model"
"golang.org/x/oauth2"
)
const (
cfgToken = "token"
cfgUsername = "username"
cfgRepo = "repo"
)
var prompts = map[string]string{
cfgToken: "GitHub Token",
cfgUsername: "GitHub Username",
cfgRepo: "GitHub Repository",
}
// Prompts are human-readable configuration element names
func (g *githubPlugin) Prompts() map[string]string {
return prompts
}
// Register causes the Github plugin to register itself
func Register() {
model.Register(model.Github, &githubPlugin{})
}
type githubPlugin struct {
token string
username string
reponame string
clientMu sync.Mutex
client *github.Client
}
func (g *githubPlugin) api() *github.Client {
g.clientMu.Lock()
defer g.clientMu.Unlock()
if g.client == nil {
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: g.token},
)
// get go-github client
gh := github.NewClient(oauth2.NewClient(context.Background(), ts))
g.client = gh
}
return g.client
}
func (g *githubPlugin) Get(ID string) (*model.Ticket, error) {
return nil, nil
}
func (g *githubPlugin) Configure(cfg map[string]interface{}) error {
var err error
if g.token, err = getCfg(cfg, cfgToken); err != nil {
return err
}
if g.username, err = getCfg(cfg, cfgUsername); err != nil {
return err
}
if g.reponame, err = getCfg(cfg, cfgRepo); err != nil {
return err
}
return nil
}
func getCfg(cfg map[string]interface{}, k string) (string, error) {
v, ok := cfg[k]
if !ok {
return "", errors.New("Missing key: " + k)
}
vS, ok := v.(string)
if !ok {
return "", errors.New("Malformatted key: " + k)
}
return vS, nil
}
func (g *githubPlugin) FindOpen() ([]*model.Ticket, error) {
issues, _, err := g.api().Issues.ListByRepo(context.Background(), g.username, g.reponame, &github.IssueListByRepoOptions{
State: "open",
})
if err != nil {
return nil, errors.Wrap(err, "error during FindOpen")
}
return toTickets(issues), nil
}
func (g *githubPlugin) FindByTag(name, value string) ([]*model.Ticket, error) {
panic("not implemented")
}
func (g *githubPlugin) FindByTagName(name string) ([]*model.Ticket, error) {
issues, _, err := g.api().Issues.ListByRepo(context.Background(), g.username, g.reponame, &github.IssueListByRepoOptions{
State: "all",
Labels: []string{name},
})
if err != nil {
return nil, errors.Wrap(err, "error during FindOpen")
}
return toTickets(issues), nil
}
func (g *githubPlugin) LinkFor(t *model.Ticket) string {
return fmt.Sprintf("https://github.com/strongdm/comply/issues/%s", t.ID)
}
func (g *githubPlugin) Links() (string, string) {
return fmt.Sprintf("https://github.com/strongdm/comply/issues?q=is%3Aissue+is%3Aclosed+label%3Acomply", g.username, g.reponame),
fmt.Sprintf("https://github.com/%s/%s/issues?q=is%3Aissue+is%3Aopen+label%3Acomply", g.username, g.reponame)
}
func (g *githubPlugin) Create(ticket *model.Ticket, labels []string) error {
_, _, err := g.api().Issues.Create(context.Background(), g.username, g.reponame, &github.IssueRequest{
Title: &ticket.Name,
Body: &ticket.Body,
Labels: &labels,
})
return err
}
func toTickets(issues []*github.Issue) []*model.Ticket {
var tickets []*model.Ticket
for _, i := range issues {
tickets = append(tickets, toTicket(i))
}
return tickets
}
func toTicket(i *github.Issue) *model.Ticket {
t := &model.Ticket{Attributes: make(map[string]interface{})}
t.ID = strconv.Itoa(*i.Number)
t.Name = ss(i.Title)
t.Body = ss(i.Body)
t.CreatedAt = i.CreatedAt
t.State = toState(ss(i.State))
for _, l := range i.Labels {
if l.Name != nil {
t.SetBool(*l.Name)
}
}
return t
}
func toState(state string) model.TicketState {
switch state {
case "closed":
return model.Closed
}
return model.Open
}
func ss(s *string) string {
if s == nil {
return ""
}
return *s
}

View File

@@ -0,0 +1,142 @@
package render
import (
"fmt"
"sort"
"time"
"github.com/strongdm/comply/internal/config"
"github.com/strongdm/comply/internal/model"
)
type project struct {
OrganizationName string
Name string
}
type stats struct {
ControlsTotal int
ControlsSatisfied int
ProcessTotal int
ProcessOpen int
ProcessOldestDays int
AuditOpen int
AuditClosed int
AuditTotal int
}
type renderData struct {
// duplicates Project.OrganizationName
Name string
Project *project
Stats *stats
Narratives []*model.Narrative
Policies []*model.Policy
Procedures []*model.Procedure
Standards []*model.Standard
Tickets []*model.Ticket
Controls []*control
}
type control struct {
Standard string
ControlKey string
Name string
Description string
Satisfied bool
SatisfiedBy []string
}
func load() (*model.Data, *renderData, error) {
modelData, err := model.ReadData()
if err != nil {
return nil, nil, err
}
cfg := config.Config()
project := &project{
OrganizationName: cfg.Name,
Name: fmt.Sprintf("%s Compliance Program", cfg.Name),
}
satisfied := model.ControlsSatisfied(modelData)
controls := make([]*control, 0)
for _, standard := range modelData.Standards {
for key, c := range standard.Controls {
satisfactions, ok := satisfied[key]
satisfied := ok && len(satisfactions) > 0
controls = append(controls, &control{
Standard: standard.Name,
ControlKey: key,
Name: c.Name,
Description: c.Description,
Satisfied: satisfied,
SatisfiedBy: satisfactions,
})
}
}
sort.Slice(controls, func(i, j int) bool {
return controls[i].ControlKey < controls[j].ControlKey
})
rd := &renderData{}
rd.Narratives = modelData.Narratives
rd.Policies = modelData.Policies
rd.Procedures = modelData.Procedures
rd.Standards = modelData.Standards
rd.Tickets = modelData.Tickets
rd.Project = project
rd.Name = project.OrganizationName
rd.Controls = controls
return modelData, rd, nil
}
func loadWithStats() (*model.Data, *renderData, error) {
modelData, renderData, err := load()
if err != nil {
return nil, nil, err
}
addStats(modelData, renderData)
return modelData, renderData, nil
}
func addStats(modelData *model.Data, renderData *renderData) {
stats := &stats{}
satisfied := model.ControlsSatisfied(modelData)
for _, std := range renderData.Standards {
stats.ControlsTotal += len(std.Controls)
for controlKey := range std.Controls {
if _, ok := satisfied[controlKey]; ok {
stats.ControlsSatisfied++
}
}
}
for _, t := range renderData.Tickets {
if t.Bool("audit") {
stats.AuditTotal++
}
if t.State == model.Open {
if t.Bool("process") {
stats.ProcessOpen++
if t.CreatedAt != nil {
age := int(time.Since(*t.CreatedAt).Hours() / float64(24))
if stats.ProcessOldestDays < age {
stats.ProcessOldestDays = age
}
}
}
if t.Bool("audit") {
stats.AuditOpen++
}
}
}
renderData.Stats = stats
}

4
internal/render/doc.go Normal file
View File

@@ -0,0 +1,4 @@
/*
Package render defines markdown preprocessors, HTML and PDF generation.
*/
package render

80
internal/render/html.go Normal file
View File

@@ -0,0 +1,80 @@
package render
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"sync"
"github.com/pkg/errors"
"github.com/yosssi/ace"
)
const websocketReloader = `<script>
(function(){
var ws = new WebSocket("ws://localhost:5122/ws")
var connected = false
ws.onopen = function(e) {
connected = true
}
ws.onclose = function(e) {
// reload!
if (connected) {
window.location=window.location
}
}
})()
</script>`
func html(output string, live bool, errCh chan error, wg *sync.WaitGroup) {
for {
files, err := ioutil.ReadDir(filepath.Join(".", "templates"))
if err != nil {
errCh <- errors.Wrap(err, "unable to open template directory")
return
}
_, data, err := loadWithStats()
if err != nil {
errCh <- errors.Wrap(err, "unable to load data")
return
}
for _, fileInfo := range files {
if !strings.HasSuffix(fileInfo.Name(), ".ace") {
continue
}
basename := strings.Replace(fileInfo.Name(), ".ace", "", -1)
w, err := os.Create(filepath.Join(output, fmt.Sprintf("%s.html", basename)))
if err != nil {
errCh <- errors.Wrap(err, "unable to create HTML file")
return
}
tpl, err := ace.Load("", filepath.Join("templates", basename), aceOpts)
if err != nil {
w.Write([]byte("<htmL><body>template error</body></html>"))
fmt.Println(err)
}
err = tpl.Execute(w, data)
if err != nil {
w.Write([]byte("<htmL><body>template error</body></html>"))
fmt.Println(err)
}
if live {
w.Write([]byte(websocketReloader))
}
w.Close()
}
if !live {
wg.Done()
return
}
<-subscribe()
}
}

View File

@@ -0,0 +1,185 @@
package render
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"sync"
"text/template"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/pkg/errors"
"github.com/strongdm/comply/internal/config"
"github.com/strongdm/comply/internal/model"
)
// TODO: refactor and eliminate duplication among narrative, policy renderers
func renderNarrativeToDisk(wg *sync.WaitGroup, errOutputCh chan error, data *renderData, narrative *model.Narrative, live bool) {
// only files that have been touched
if !isNewer(narrative.FullPath, narrative.ModifiedAt) {
return
}
recordModified(narrative.FullPath, narrative.ModifiedAt)
ctx := context.Background()
cli, err := client.NewEnvClient()
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to read Docker environment")
return
}
pwd, err := os.Getwd()
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to get workding directory")
return
}
hc := &container.HostConfig{
Binds: []string{pwd + ":/source"},
}
wg.Add(1)
go func(p *model.Narrative) {
defer wg.Done()
if live {
rel, err := filepath.Rel(config.ProjectRoot(), p.FullPath)
if err != nil {
rel = p.FullPath
}
fmt.Printf("%s -> %s\n", rel, filepath.Join("output", p.OutputFilename))
}
outputFilename := p.OutputFilename
// save preprocessed markdown
err = preprocessNarrative(data, p, filepath.Join(".", "output", outputFilename+".md"))
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to preprocess")
return
}
cmd := []string{"--smart", "--toc", "-N", "--template=/source/templates/default.latex", "-o",
fmt.Sprintf("/source/output/%s", outputFilename),
fmt.Sprintf("/source/output/%s.md", outputFilename)}
resp, err := cli.ContainerCreate(ctx, &container.Config{
Image: "strongdm/pandoc",
Cmd: cmd},
hc, nil, "")
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to create Docker container")
return
}
defer func() {
timeout := 2 * time.Second
cli.ContainerStop(ctx, resp.ID, &timeout)
err := cli.ContainerRemove(ctx, resp.ID, types.ContainerRemoveOptions{Force: true})
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to remove container")
return
}
}()
if err := cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil {
errOutputCh <- errors.Wrap(err, "unable to start Docker container")
return
}
_, err = cli.ContainerWait(ctx, resp.ID)
if err != nil {
errOutputCh <- errors.Wrap(err, "error awaiting Docker container")
return
}
_, err = cli.ContainerLogs(ctx, resp.ID, types.ContainerLogsOptions{ShowStdout: true})
if err != nil {
errOutputCh <- errors.Wrap(err, "error reading Docker container logs")
return
}
// remove preprocessed markdown
err = os.Remove(filepath.Join(".", "output", outputFilename+".md"))
if err != nil {
errOutputCh <- err
return
}
}(narrative)
}
func preprocessNarrative(data *renderData, pol *model.Narrative, fullPath string) error {
cfg := config.Config()
var w bytes.Buffer
bodyTemplate, err := template.New("body").Parse(pol.Body)
if err != nil {
w.WriteString(fmt.Sprintf("# Error processing template:\n\n%s\n", err.Error()))
} else {
bodyTemplate.Execute(&w, data)
}
body := w.String()
revisionTable := ""
satisfiesTable := ""
// ||Date|Comment|
// |---+------|
// | 4 Jan 2018 | Initial Version |
// Table: Document history
if len(pol.Satisfies) > 0 {
rows := ""
for standard, keys := range pol.Satisfies {
rows += fmt.Sprintf("| %s | %s |\n", standard, strings.Join(keys, ", "))
}
satisfiesTable = fmt.Sprintf("|Standard|Controls Satisfied|\n|-------+--------------------------------------------|\n%s\nTable: Control satisfaction\n", rows)
}
if len(pol.Revisions) > 0 {
rows := ""
for _, rev := range pol.Revisions {
rows += fmt.Sprintf("| %s | %s |\n", rev.Date, rev.Comment)
}
revisionTable = fmt.Sprintf("|Date|Comment|\n|---+--------------------------------------------|\n%s\nTable: Document history\n", rows)
}
doc := fmt.Sprintf(`%% %s
%% %s
%% %s
---
header-includes: yes
head-content: "%s"
foot-content: "%s confidential %d"
---
%s
%s
\newpage
%s`,
pol.Name,
cfg.Name,
fmt.Sprintf("%s %d", pol.ModifiedAt.Month().String(), pol.ModifiedAt.Year()),
pol.Name,
cfg.Name,
time.Now().Year(),
satisfiesTable,
revisionTable,
body,
)
err = ioutil.WriteFile(fullPath, []byte(doc), os.FileMode(0644))
if err != nil {
return errors.Wrap(err, "unable to write preprocessed narrative to disk")
}
return nil
}

49
internal/render/pdf.go Normal file
View File

@@ -0,0 +1,49 @@
package render
import (
"sync"
"github.com/pkg/errors"
"github.com/strongdm/comply/internal/model"
)
func pdf(output string, live bool, errCh chan error, wg *sync.WaitGroup) {
var pdfWG sync.WaitGroup
errOutputCh := make(chan error)
for {
_, data, err := loadWithStats()
if err != nil {
errCh <- errors.Wrap(err, "unable to load data")
return
}
policies, err := model.ReadPolicies()
if err != nil {
errCh <- errors.Wrap(err, "unable to read policies")
return
}
for _, policy := range policies {
renderPolicyToDisk(&pdfWG, errOutputCh, data, policy, live)
}
narratives, err := model.ReadNarratives()
if err != nil {
errCh <- errors.Wrap(err, "unable to read narratives")
return
}
for _, narrative := range narratives {
renderNarrativeToDisk(&pdfWG, errOutputCh, data, narrative, live)
}
pdfWG.Wait()
if !live {
wg.Done()
return
}
<-subscribe()
}
}

183
internal/render/policy.go Normal file
View File

@@ -0,0 +1,183 @@
package render
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"sync"
"text/template"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/pkg/errors"
"github.com/strongdm/comply/internal/config"
"github.com/strongdm/comply/internal/model"
)
// TODO: refactor and eliminate duplication among narrative, policy renderers
func renderPolicyToDisk(wg *sync.WaitGroup, errOutputCh chan error, data *renderData, policy *model.Policy, live bool) {
// only files that have been touched
if !isNewer(policy.FullPath, policy.ModifiedAt) {
return
}
recordModified(policy.FullPath, policy.ModifiedAt)
ctx := context.Background()
cli, err := client.NewEnvClient()
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to read Docker environment")
return
}
pwd, err := os.Getwd()
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to get workding directory")
return
}
hc := &container.HostConfig{
Binds: []string{pwd + ":/source"},
}
wg.Add(1)
go func(p *model.Policy) {
defer wg.Done()
if live {
rel, err := filepath.Rel(config.ProjectRoot(), p.FullPath)
if err != nil {
rel = p.FullPath
}
fmt.Printf("%s -> %s\n", rel, filepath.Join("output", p.OutputFilename))
}
outputFilename := p.OutputFilename
// save preprocessed markdown
err = preprocessPolicy(data, p, filepath.Join(".", "output", outputFilename+".md"))
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to preprocess")
return
}
resp, err := cli.ContainerCreate(ctx, &container.Config{
Image: "strongdm/pandoc",
Cmd: []string{"--smart", "--toc", "-N", "--template=/source/templates/default.latex", "-o",
fmt.Sprintf("/source/output/%s", outputFilename),
fmt.Sprintf("/source/output/%s.md", outputFilename),
},
}, hc, nil, "")
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to create Docker container")
return
}
defer func() {
timeout := 2 * time.Second
cli.ContainerStop(ctx, resp.ID, &timeout)
err := cli.ContainerRemove(ctx, resp.ID, types.ContainerRemoveOptions{Force: true})
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to remove container")
return
}
}()
if err := cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil {
errOutputCh <- errors.Wrap(err, "unable to start Docker container")
return
}
_, err = cli.ContainerWait(ctx, resp.ID)
if err != nil {
errOutputCh <- errors.Wrap(err, "error awaiting Docker container")
return
}
_, err = cli.ContainerLogs(ctx, resp.ID, types.ContainerLogsOptions{ShowStdout: true})
if err != nil {
errOutputCh <- errors.Wrap(err, "error reading Docker container logs")
return
}
// remove preprocessed markdown
err = os.Remove(filepath.Join(".", "output", outputFilename+".md"))
if err != nil {
errOutputCh <- err
return
}
}(policy)
}
func preprocessPolicy(data *renderData, pol *model.Policy, fullPath string) error {
cfg := config.Config()
var w bytes.Buffer
bodyTemplate, err := template.New("body").Parse(pol.Body)
if err != nil {
w.WriteString(fmt.Sprintf("# Error processing template:\n\n%s\n", err.Error()))
} else {
bodyTemplate.Execute(&w, data)
}
body := w.String()
revisionTable := ""
satisfiesTable := ""
// ||Date|Comment|
// |---+------|
// | 4 Jan 2018 | Initial Version |
// Table: Document history
if len(pol.Satisfies) > 0 {
rows := ""
for standard, keys := range pol.Satisfies {
rows += fmt.Sprintf("| %s | %s |\n", standard, strings.Join(keys, ", "))
}
satisfiesTable = fmt.Sprintf("|Standard|Controls Satisfied|\n|-------+--------------------------------------------|\n%s\nTable: Control satisfaction\n", rows)
}
if len(pol.Revisions) > 0 {
rows := ""
for _, rev := range pol.Revisions {
rows += fmt.Sprintf("| %s | %s |\n", rev.Date, rev.Comment)
}
revisionTable = fmt.Sprintf("|Date|Comment|\n|---+--------------------------------------------|\n%s\nTable: Document history\n", rows)
}
doc := fmt.Sprintf(`%% %s
%% %s
%% %s
---
header-includes: yes
head-content: "%s"
foot-content: "%s confidential %d"
---
%s
%s
\newpage
%s`,
pol.Name,
cfg.Name,
fmt.Sprintf("%s %d", pol.ModifiedAt.Month().String(), pol.ModifiedAt.Year()),
pol.Name,
cfg.Name,
time.Now().Year(),
satisfiesTable,
revisionTable,
body,
)
err = ioutil.WriteFile(fullPath, []byte(doc), os.FileMode(0644))
if err != nil {
return errors.Wrap(err, "unable to write preprocessed policy to disk")
}
return nil
}

118
internal/render/site.go Normal file
View File

@@ -0,0 +1,118 @@
package render
import (
"net/http"
"os"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/pkg/errors"
"github.com/skratchdot/open-golang/open"
"github.com/yosssi/ace"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
var aceOpts = &ace.Options{
DynamicReload: true,
Indent: " ",
}
var watchChMu sync.Mutex
var watchCh chan struct{}
func subscribe() chan struct{} {
watchChMu.Lock()
defer watchChMu.Unlock()
if watchCh == nil {
watchCh = make(chan struct{})
}
return watchCh
}
func broadcast() {
watchChMu.Lock()
defer watchChMu.Unlock()
close(watchCh)
watchCh = nil
}
var lastModifiedMu sync.Mutex
var lastModified = make(map[string]time.Time)
func recordModified(path string, t time.Time) {
lastModifiedMu.Lock()
defer lastModifiedMu.Unlock()
previous, ok := lastModified[path]
if !ok || t.After(previous) {
lastModified[path] = t
}
}
func isNewer(path string, t time.Time) bool {
lastModifiedMu.Lock()
defer lastModifiedMu.Unlock()
previous, ok := lastModified[path]
if !ok {
return true
}
// is tested after previous? Then isNewer is true.
return t.After(previous)
}
// Build generates all PDF and HTML output to the target directory with optional live reload.
func Build(output string, live bool) error {
err := os.RemoveAll(output)
if err != nil {
errors.Wrap(err, "unable to remove files from output directory")
}
err = os.MkdirAll(output, os.FileMode(0755))
if err != nil {
errors.Wrap(err, "unable to create output directory")
}
var wg sync.WaitGroup
errCh := make(chan error, 0)
wgCh := make(chan struct{})
if live {
watch(errCh)
}
// PDF
wg.Add(1)
go pdf(output, live, errCh, &wg)
// HTML
wg.Add(1)
go html(output, live, errCh, &wg)
// WG monitor
go func() {
wg.Wait()
close(wgCh)
}()
if live {
open.Run("output/index.html")
}
select {
case <-wgCh:
// success
case err := <-errCh:
return errors.Wrap(err, "error during build")
}
return nil
}

50
internal/render/watch.go Normal file
View File

@@ -0,0 +1,50 @@
package render
import (
"net/http"
"time"
"github.com/gohugoio/hugo/watcher"
)
func watch(errCh chan error) {
b, err := watcher.New(300 * time.Millisecond)
if err != nil {
errCh <- err
return
}
b.Add("./templates/")
b.Add("./policies/")
b.Add("./procedures/")
b.Add("./.comply/")
b.Add("./.comply/cache")
b.Add("./.comply/cache/tickets")
go func() {
for {
select {
case e := <-b.Errors:
errCh <- e
case <-b.Events:
broadcast()
}
}
}()
serveWs := func(w http.ResponseWriter, r *http.Request) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
errCh <- err
return
}
<-subscribe()
time.Sleep(500 * time.Millisecond)
ws.Close()
}
http.HandleFunc("/ws", serveWs)
go http.ListenAndServe("127.0.0.1:5122", nil)
return
}

1
internal/theme/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
themes_bindata.go

28
internal/theme/theme.go Normal file
View File

@@ -0,0 +1,28 @@
package theme
import (
"io/ioutil"
"os"
"path/filepath"
"strings"
)
func SaveTo(themeName, saveDir string) error {
for _, name := range AssetNames() {
prefix := themeName + "/"
if strings.HasPrefix(name, prefix) {
outputName := strings.TrimPrefix(name, prefix)
assetDir, assetFilename := filepath.Split(outputName)
err := os.MkdirAll(filepath.Join(saveDir, assetDir), os.FileMode(0755))
if err != nil {
return err
}
err = ioutil.WriteFile(filepath.Join(saveDir, assetDir, assetFilename), MustAsset(name), os.FileMode(0644))
if err != nil {
return err
}
}
}
// TODO
return nil
}

View File

@@ -0,0 +1,107 @@
package ticket
import (
"fmt"
"sort"
"time"
"github.com/robfig/cron"
"github.com/strongdm/comply/internal/model"
)
func byProcessByTime(tickets []*model.Ticket) map[string][]*model.Ticket {
result := make(map[string][]*model.Ticket)
for _, t := range tickets {
processID := t.ProcedureID()
if processID == "" {
// missing process metadata; skip
continue
}
list, ok := result[processID]
if !ok {
list = make([]*model.Ticket, 0)
}
list = append(list, t)
sort.Slice(list, func(i, j int) bool {
if list[i].CreatedAt == nil || list[j].CreatedAt == nil {
return false
}
return list[i].CreatedAt.Before(*list[j].CreatedAt)
})
result[processID] = list
}
return result
}
func TriggerScheduled() error {
rawTickets, err := model.ReadTickets()
if err != nil {
return err
}
tickets := byProcessByTime(rawTickets)
procedures, err := model.ReadProcedures()
if err != nil {
return err
}
for _, procedure := range procedures {
if procedure.Cron == "" {
continue
}
procedureID := procedure.ID
schedule, err := cron.Parse(procedure.Cron)
if err != nil {
continue
}
ticketsForProc, ok := tickets[procedureID]
if ok {
// find most recent one
mostRecent := ticketsForProc[len(ticketsForProc)-1]
if mostRecent.CreatedAt == nil {
continue
}
// would another have triggered since?
nextTrigger := schedule.Next(*mostRecent.CreatedAt).UTC()
if nextTrigger.After(time.Now().UTC()) {
// in the future, nothing to do
continue
}
trigger(procedure)
} else {
// don't go back further than 13 months
tooOld := time.Now().Add(-1 * time.Hour * 24 * (365 + 30))
// search back one day until triggers
triggeredAt := time.Now().Add(-24 * time.Hour).UTC()
SEARCH:
for {
if triggeredAt.Before(tooOld) {
break SEARCH
}
candidate := schedule.Next(triggeredAt)
// in the future? not far eonugh back yet.
if candidate.After(time.Now().UTC()) {
triggeredAt = triggeredAt.Add(-24 * time.Hour)
continue
}
// is in the past? then trigger.
trigger(procedure)
break SEARCH
}
}
}
return nil
}
func trigger(procedure *model.Procedure) {
// TODO: don't hardcode GH
tp := model.GetPlugin(model.Github)
tp.Create(&model.Ticket{
Name: procedure.Name,
Body: fmt.Sprintf("%s\n\n\n---\nProcedure-ID: %s", procedure.Body, procedure.ID),
}, []string{"comply", "comply-procedure"})
}