mirror of
https://github.com/strongdm/comply
synced 2025-12-06 14:24:12 +00:00
Initial commit
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user