mirror of
https://github.com/strongdm/comply
synced 2025-12-06 14:24:12 +00:00
Initial commit
This commit is contained in:
118
internal/cli/app.go
Normal file
118
internal/cli/app.go
Normal file
@@ -0,0 +1,118 @@
|
||||
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.
|
||||
var Version = ""
|
||||
|
||||
// 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"
|
||||
if Version == "" {
|
||||
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
29
internal/cli/build.go
Normal 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
4
internal/cli/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
/*
|
||||
Package cli defines comply commands and arguments.
|
||||
*/
|
||||
package cli
|
||||
153
internal/cli/init.go
Normal file
153
internal/cli/init.go
Normal 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
21
internal/cli/scheduler.go
Normal 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
21
internal/cli/serve.go
Normal 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
28
internal/cli/sync.go
Normal 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
68
internal/cli/todo.go
Normal 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
66
internal/config/config.go
Normal 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
4
internal/config/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
/*
|
||||
Package config provides access to the comply.yml file.
|
||||
*/
|
||||
package config
|
||||
6
internal/model/audit.go
Normal file
6
internal/model/audit.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package model
|
||||
|
||||
type Audit struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
36
internal/model/db.go
Normal file
36
internal/model/db.go
Normal 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
35
internal/model/db_test.go
Normal 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
14
internal/model/doc.go
Normal 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
188
internal/model/fs.go
Normal 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
17
internal/model/model.go
Normal 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
|
||||
44
internal/model/model_test.go
Normal file
44
internal/model/model_test.go
Normal 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")
|
||||
}
|
||||
|
||||
}
|
||||
15
internal/model/narrative.go
Normal file
15
internal/model/narrative.go
Normal 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
89
internal/model/plugin.go
Normal 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
15
internal/model/policy.go
Normal 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
|
||||
}
|
||||
16
internal/model/procedure.go
Normal file
16
internal/model/procedure.go
Normal 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
|
||||
}
|
||||
49
internal/model/standard.go
Normal file
49
internal/model/standard.go
Normal 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
65
internal/model/ticket.go
Normal 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
4
internal/path/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
/*
|
||||
Package path provides convenient access to comply project path conventions.
|
||||
*/
|
||||
package path
|
||||
55
internal/path/path.go
Normal file
55
internal/path/path.go
Normal 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
|
||||
}
|
||||
179
internal/plugin/github/github.go
Normal file
179
internal/plugin/github/github.go
Normal 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
|
||||
}
|
||||
142
internal/render/controller.go
Normal file
142
internal/render/controller.go
Normal 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
4
internal/render/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
/*
|
||||
Package render defines markdown preprocessors, HTML and PDF generation.
|
||||
*/
|
||||
package render
|
||||
80
internal/render/html.go
Normal file
80
internal/render/html.go
Normal 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()
|
||||
}
|
||||
}
|
||||
185
internal/render/narrative.go
Normal file
185
internal/render/narrative.go
Normal 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
49
internal/render/pdf.go
Normal 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
183
internal/render/policy.go
Normal 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
118
internal/render/site.go
Normal 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
50
internal/render/watch.go
Normal 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
1
internal/theme/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
themes_bindata.go
|
||||
28
internal/theme/theme.go
Normal file
28
internal/theme/theme.go
Normal 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
|
||||
}
|
||||
107
internal/ticket/scheduler.go
Normal file
107
internal/ticket/scheduler.go
Normal 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"})
|
||||
}
|
||||
Reference in New Issue
Block a user