Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,83 @@ docker run \

See the [OSPS Security Baseline Scanner](https://github.com/marketplace/actions/open-source-project-security-baseline-scanner)

## Optional AI Review of Security Insights Evidence

When an AI provider is configured, four security-assessment checks can review
the contents of evidence explicitly linked by Security Insights:

| Requirement | Security Insights evidence selected |
| --- | --- |
| OSPS-SA-01.01: design documentation | Project documentation's detailed guide |
| OSPS-SA-02.01: external interfaces | Project documentation's detailed and quickstart guides |
| OSPS-SA-03.01: security assessment | Repository security posture's self and third-party assessment evidence |
| OSPS-SA-03.02: threat modeling | Repository security posture's self and third-party assessment evidence |

This is document review, not repository-wide discovery or an independent
verification of the released software. Only HTTPS `github.com/.../blob/...`
and `raw.githubusercontent.com/...` file links in the repository being
assessed are supported. Each link's ref is used rather than silently reading
the default branch; slash-containing refs must encode the slash as `%2F`.
Query strings, credentials in URLs, and redirects are not supported. URL
fragments select no smaller scope: the entire declared file is reviewed.
Supported text formats are Markdown, AsciiDoc, reStructuredText,
plain text, JSON, YAML, and Protocol Buffers. Links within an artifact are not
followed.

- Without AI configuration, no additional evidence is fetched and deterministic
evaluation is used. With no relevant Security Insights declaration, it is also
used unchanged; the scanner does not search for alternative AI evidence.
- Comment-only or name-only assessments, unsupported URLs or formats (including
PDFs), retrieval errors, and evidence exceeding 16 distinct URLs or the 64 KiB
JSON packet budget result in **NeedsReview** without calling the model. An
incomplete set of declared artifacts is not graded.
- AI configuration, provider, or response-validation failures return
**NeedsReview** with low confidence, not a new Passed or Failed verdict.
This is conservative fallback, not preservation of an earlier Pass or Fail.
- A successful AI response can change the deterministic verdict. Design
passes are capped at medium confidence: documentary coverage is not proof
that every released component was documented.
AI NeedsReview verdicts use low confidence, even when the model reports
high confidence in its deferral.
- For OSPS-SA-02.01, every AI pass recommendation returns **NeedsReview** with
low confidence and an explicit request for human confirmation. Live tests
showed inconsistent acceptance of insufficient interface documentation.
The original model verdict, explanation, and citations remain in the AI
evidence for review; they are recommendations, not the final scanner result.
AI Failed and NeedsReview responses keep their normal result handling.
This rule does not change the AI-disabled deterministic path.

The design check also now applies the release gate already used by the other
three checks. Release detection currently uses GitHub Releases, not tags alone
or releases distributed elsewhere.

Declared document contents are sent to the configured AI provider. Use this
option only when that provider is approved to process the repository's data.
AI judgments remain subject to error and require human review where assurance
or compliance decisions depend on them.

### Opt-in live prompt regression tests

The normal Go test suite does not call an AI provider. To replay captured
SI-declared evidence through an approved provider, set `PVTR_SA_LIVE_FIXTURE`,
`PVTR_SA_LIVE_MODEL`, `PVTR_SA_LIVE_BASE_URL`, and `PVTR_SA_LIVE_API_KEY`, then run:

```sh
go test ./evaluation_plans/osps/sec_assessment \
-run '^TestSecurityAssessmentDeclaredEvidenceLive$' -count=1 -v
```

The fixture is a JSON array of cases with `name`, `behavior`, `material`, and
`want_result` fields. `material` is the captured evidence object supplied to
the model; `want_result` is the human-reviewed expected Gemara result, such as
`Needs Review` or `Passed`. Preserve the original SI declarations and document
source URLs when preparing fixtures. Set `PVTR_SA_LIVE_OUTPUT` to retain the
AI evidence for review; it includes the supplied document contents.

This isolates prompt and verdict handling from repository retrieval. It is
not a replacement for full scanner end-to-end tests, and passing a finite
set of cases does not guarantee model accuracy on other evidence.

## Best Practices Badge Integration

To use scan results with the OpenSSF Best Practices Badge, see the user guide in
Expand Down
165 changes: 165 additions & 0 deletions data/declared_documentation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package data

import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"unicode"
"unicode/utf8"

"github.com/google/go-github/v74/github"
)

const maxDeclaredDocumentationBytes = 64 * 1024

type declaredDocumentationResult struct {
file DocumentationFile
err error
}

// GetDeclaredDocumentation reads only the same-repository text artifact named by
// a Security Insights URL, at its declared ref. Results, including errors and
// empty documents, are shared across steps through the payload cache.
func (p *Payload) GetDeclaredDocumentation(rawURL string) (DocumentationFile, error) {
if p == nil {
return DocumentationFile{}, errors.New("payload missing required repository data")
}
if p.cache == nil {
p.cache = &payloadCache{}
}
if result, ok := p.cache.declaredDocumentation[rawURL]; ok {
return result.file, result.err
}
file, err := p.getDeclaredDocumentation(rawURL)
if p.cache.declaredDocumentation == nil {
p.cache.declaredDocumentation = make(map[string]declaredDocumentationResult)
}
p.cache.declaredDocumentation[rawURL] = declaredDocumentationResult{file: file, err: err}
return file, err
}

func (r *RestData) getDeclaredDocumentation(rawURL string) (DocumentationFile, error) {
if r == nil || r.owner == "" || r.repo == "" {
return DocumentationFile{}, errors.New("payload missing required repository identity")
}
ref, filePath, err := parseDeclaredDocumentationURL(rawURL, r.owner, r.repo)
if err != nil {
return DocumentationFile{}, err
}
if r.ghClient == nil {
return DocumentationFile{}, errors.New("payload missing GitHub API client")
}

// Reuse the configured API transport (including authentication and counting),
// but never follow redirects: an OAuth transport can attach credentials again
// even when net/http strips Authorization on a cross-host redirect.
httpClient := *r.ghClient.Client()
httpClient.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
client := github.NewClient(&httpClient)
client.BaseURL = r.ghClient.BaseURL
entry, _, _, err := client.Repositories.GetContents(context.Background(), r.owner, r.repo, filePath, &github.RepositoryContentGetOptions{Ref: ref})
if err != nil {
return DocumentationFile{}, fmt.Errorf("read declared documentation %s at ref %s: %w", filePath, ref, err)
}
if entry == nil || entry.GetType() != "file" || entry.GetTarget() != "" || entry.GetSubmoduleGitURL() != "" {
return DocumentationFile{}, errors.New("declared documentation is not a regular file")
}
if entry.GetPath() != filePath {
return DocumentationFile{}, errors.New("declared documentation response path does not match requested path")
}
if entry.GetSize() < 0 {
return DocumentationFile{}, errors.New("declared documentation has an invalid file size")
}
if entry.GetSize() > maxDeclaredDocumentationBytes {
return DocumentationFile{}, fmt.Errorf("declared documentation exceeds %d bytes", maxDeclaredDocumentationBytes)
}
if entry.Content == nil || entry.GetEncoding() != "base64" {
return DocumentationFile{}, errors.New("declared documentation has missing content or unsupported encoding")
}
decoded, err := io.ReadAll(io.LimitReader(
base64.NewDecoder(base64.StdEncoding, strings.NewReader(*entry.Content)),
maxDeclaredDocumentationBytes+1,
))
if err != nil {
return DocumentationFile{}, fmt.Errorf("decode declared documentation: %w", err)
}
if len(decoded) > maxDeclaredDocumentationBytes {
return DocumentationFile{}, fmt.Errorf("declared documentation exceeds %d bytes", maxDeclaredDocumentationBytes)
}
text := string(decoded)
if !utf8.ValidString(text) || strings.ContainsFunc(text, func(r rune) bool {
return unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 (medium) unicode.IsControl returns false for every rune above U+00FF, so this filter admits the entire Cf category: zero-width spaces, bidi overrides, and the U+E0000 tag block used for hidden-text prompt injection. Fetched content is fully controlled by the scanned repo, and for SA-01.01/SA-03.01/SA-03.02 a model pass becomes Passed directly (at High confidence for SA-03.x, since the cap only covers design).

Suggested change
return unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t'
return (unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t') || unicode.Is(unicode.Cf, r)

Also worth considering extending the confidence cap or pass deferral to the SA-03.x behaviors.

}) || (len(decoded) > 0 && !strings.HasPrefix(http.DetectContentType(decoded), "text/")) {
return DocumentationFile{}, errors.New("declared documentation is not UTF-8 text")
}
return DocumentationFile{Path: filePath, Content: text}, nil
}

func parseDeclaredDocumentationURL(rawURL, owner, repo string) (ref, filePath string, err error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", "", errors.New("invalid declared documentation URL")
}
if u.Scheme != "https" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Opaque != "" {
return "", "", errors.New("declared documentation URL must use HTTPS without userinfo or query strings")
}
host := strings.ToLower(u.Host)
if host != "github.com" && host != "raw.githubusercontent.com" {
return "", "", errors.New("declared documentation URL must use github.com or raw.githubusercontent.com")
}
// Split before unescaping so a slash-containing ref stays one URL segment.
segments := strings.Split(strings.TrimPrefix(u.EscapedPath(), "/"), "/")
refIndex := 2

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 (medium) GitHub's Raw button now emits https://raw.githubusercontent.com/owner/repo/refs/heads/main/docs/design.md. With refIndex fixed at 2 this parses as ref = "refs", filePath = "heads/main/...", the fetch 404s, and the check silently lands in NeedsReview instead of being graded. Fails safe, but broken for the URL format GitHub currently hands out, and the tests only cover the legacy /owner/repo/<ref>/path form. Suggest consuming refs/heads/<name> and refs/tags/<name> as the ref; the Contents API accepts fully qualified refs.

if host == "github.com" {
refIndex = 3
}
if len(segments) < refIndex+2 {
return "", "", errors.New("declared documentation URL is missing a ref or file path")
}
for i, segment := range segments {
decoded, decodeErr := url.PathUnescape(segment)
if decodeErr != nil || !validDeclaredDocumentationSegment(decoded, i == refIndex) {
return "", "", errors.New("declared documentation URL contains an invalid path segment")
}
segments[i] = decoded
}
if !strings.EqualFold(segments[0], owner) || !strings.EqualFold(segments[1], repo) {
return "", "", errors.New("declared documentation URL must reference the assessed repository")
}
if host == "github.com" && segments[2] != "blob" {
return "", "", errors.New("declared documentation GitHub URL must use /blob/<ref>/<path>")
}
ref = segments[refIndex]
filePath = strings.Join(segments[refIndex+1:], "/")
switch strings.ToLower(path.Ext(filePath)) {
case ".json", ".yaml", ".yml", ".proto":
default:
if !isDocumentationPath(filePath) {
return "", "", errors.New("declared documentation has an unsupported file extension")
}
}
return ref, filePath, nil
}

func validDeclaredDocumentationSegment(segment string, isRef bool) bool {
if !utf8.ValidString(segment) || strings.ContainsAny(segment, "\\%") || strings.ContainsFunc(segment, unicode.IsControl) {
return false
}
if !isRef && strings.Contains(segment, "/") {
return false
}
for _, part := range strings.Split(segment, "/") {
if part == "" || part == "." || part == ".." {
return false
}
}
return true
}
Loading