diff --git a/dmarc/report.go b/dmarc/report.go index d69a875..d844cda 100644 --- a/dmarc/report.go +++ b/dmarc/report.go @@ -3,12 +3,44 @@ package dmarc import ( "encoding/xml" "io" + "fmt" + "time" + "strconv" + "strings" + "net" ) +const AuthResultType_DKIM = "dkim" +const AuthResultType_SPF = "spf" + + type DateRange struct { - // TODO: should be int but Y! trailing spaces - Begin string `xml:"begin"` - End string `xml:"end"` + Begin time.Time + End time.Time +} + +// Unmarshal the DateRange element into time.Time objects. +// Yahoo tends to put some extra whitespace behind its +// timestamps, so we trim that first. +func (dr *DateRange) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + rangeStr := struct { + Begin string `xml:"begin"` + End string `xml:"end"` + }{} + d.DecodeElement(&rangeStr, &start) + + begin, err := strconv.Atoi(strings.TrimSpace(rangeStr.Begin)) + if err != nil { + return err + } + end, err := strconv.Atoi(strings.TrimSpace(rangeStr.End)) + if err != nil { + return err + } + + dr.Begin = time.Unix(int64(begin), 0).UTC() + dr.End = time.Unix(int64(end), 0).UTC() + return nil } type ReportMetadata struct { @@ -35,23 +67,58 @@ type PolicyEvaluated struct { } type Row struct { - // TODO: Figure out how to cast this to an IP - SourceIp string `xml:"source_ip"` + SourceIp net.IP Count int `xml:"count"` PolicyEvaluated PolicyEvaluated `xml:"policy_evaluated"` } +func (r *Row) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + rowAlias := struct { + SourceIp string `xml:"source_ip"` + Count int `xml:"count"` + PolicyEvaluated PolicyEvaluated `xml:"policy_evaluated"` + }{} + d.DecodeElement(&rowAlias, &start) + + r.Count = rowAlias.Count + r.PolicyEvaluated = rowAlias.PolicyEvaluated + r.SourceIp = net.ParseIP(rowAlias.SourceIp) + if r.SourceIp == nil { + return fmt.Errorf("Could not parse source_ip") + } + + return nil +} + type Identifiers struct { HeaderFrom string `xml:"header_from"` } type AuthResult struct { - // FIXME: this could be either DKIM or SPF - XMLName xml.Name + Type string // Either SPF or DKIM Domain string `xml:"domain"` Result string `xml:"result"` } +func (ar *AuthResult) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + res := struct { + XMLName xml.Name + Domain string `xml:"domain"` + Result string `xml:"result"` + }{} + d.DecodeElement(&res, &start) + + ar.Domain = res.Domain + ar.Result = res.Result + + if res.XMLName.Local != AuthResultType_DKIM && res.XMLName.Local != AuthResultType_SPF { + return fmt.Errorf("Unrecognized AuthResult type: %s", res.XMLName.Local) + } + + ar.Type = res.XMLName.Local + return nil +} + type AuthResults struct { AuthResult []AuthResult `xml:",any"` } @@ -63,36 +130,39 @@ type Record struct { } type FeedbackReport struct { - XMLName xml.Name `xml:"feedback"` - ReportMetadata ReportMetadata `xml:"report_metadata"` + Metadata ReportMetadata `xml:"report_metadata"` PolicyPublished PolicyPublished `xml:"policy_published"` Record []Record `xml:"record"` } -func ParseReader(xmlFileReader io.Reader) FeedbackReport { - var f FeedbackReport +func ParseReader(xmlFileReader io.Reader) (*FeedbackReport, error) { + var f *FeedbackReport decoder := xml.NewDecoder(xmlFileReader) var inElement string for { - t, _ := decoder.Token() + t, err := decoder.Token() + if t == nil && err == io.EOF { + break; + } if t == nil { - break + return nil, err } switch se := t.(type) { case xml.StartElement: inElement = se.Name.Local if inElement == "feedback" { - decoder.DecodeElement(&f, &se) - // xmlerr := decoder.DecodeElement(&f, &se) - // if xmlerr != nil { - // fmt.Printf("decode error: %v\n", xmlerr) - // } - // fmt.Printf("XMLName: %#v\n", f) + err := decoder.DecodeElement(&f, &se) + if err != nil { + return nil, err + } + } else { + return nil, fmt.Errorf("Unknown root element: %s", inElement) } default: } } - return f + + return f, nil } diff --git a/dmarc/report_test.go b/dmarc/report_test.go new file mode 100644 index 0000000..42f59c7 --- /dev/null +++ b/dmarc/report_test.go @@ -0,0 +1,115 @@ +package dmarc + +import ( + "testing" + "strings" + //"fmt" + "time" +) + +var reportXML = ` + + + Yahoo! Inc. + postmaster@dmarc.yahoo.com + 1405588706.570207 + + 1405468800 + 1405555199 + + + + example.com + r + r +

none

+ 100 +
+ + + 127.0.0.1 + 2 + + none + fail + pass + + + + example.com + + + + example.com + neutral + + + example.com + pass + + + + + + 127.0.0.2 + 988 + + none + fail + fail + + + + idfactor.example.com + + + + idfactor.example.com + neutral + + + idfactor.example.com + none + + + +
` + +func TestInvalidDocument(t *testing.T) { + xml := `` + report, err := ParseReader(strings.NewReader(xml)) + + if err == nil { + t.Errorf("Expected error, but got none") + } + + if report != nil { + t.Errorf("Report should have been but wasn't") + } +} + +func TestUnmarshalling(t *testing.T) { + report, err := ParseReader(strings.NewReader(reportXML)) + + if err != nil { + t.Errorf("Got error: %s:", err.Error()) + } + + begin := time.Unix(1405468800, 0).UTC() + if report.Metadata.DateRange.Begin != begin { + t.Errorf("report.Metadata.DateRange.Begin did not match expected date") + } + + end := time.Unix(1405555199, 0).UTC() + if report.Metadata.DateRange.End != end { + t.Errorf("report.Metadata.DateRange.end did not match expected date") + } + + if report.PolicyPublished.Domain != "example.com" { + t.Errorf("report.PolicyPublished.Domain was '%s', expected 'example.com'", report.PolicyPublished.Domain) + } + + if len(report.Record) != 2 { + t.Errorf("report.Record was expected to contain 2 items, only got: %d", len(report.Record)) + } +} \ No newline at end of file diff --git a/dmarcparser.go b/dmarcparser.go index c1cb478..7ef2330 100644 --- a/dmarcparser.go +++ b/dmarcparser.go @@ -1,9 +1,9 @@ package dmarcaggparser import ( + "dmarc" "flag" "fmt" - "dmarc" "os" )