Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
13 changes: 12 additions & 1 deletion cmd/kosli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,18 @@ The service principal needs to have the following permissions:
`
kosliIgnoreDesc = `To specify paths in a directory artifact that should always be excluded from the SHA256 calculation, you can add a ^.kosli_ignore^ file to the root of the artifact.
Each line should specify a relative path or path glob to be ignored. You can include comments in this file, using ^#^.
The ^.kosli_ignore^ will be treated as part of the artifact like any other file, unless it is explicitly ignored itself.`
The ^.kosli_ignore^ file is always treated as part of the artifact: its own entries cannot exclude it, so the exclusion list cannot be changed without changing the fingerprint.
Paths the list already matches stay excluded whatever is later added there, so keep its entries as narrow as possible.
Excluding the file with ^--exclude^ keeps it out of the fingerprint but still applies the paths it lists, which lets a writable directory change the list again.
To drop the file from the fingerprint safely, move its entries to ^--exclude^ and delete it.`
Comment thread
pbeckham marked this conversation as resolved.
Comment thread
pbeckham marked this conversation as resolved.

// snapshot s3 and snapshot azure-apps fingerprint a directory but have no
// --exclude that feeds it: s3's filters bucket paths at download time, and
// azure-apps registers none.
Comment thread
pbeckham marked this conversation as resolved.
kosliIgnoreDescNoExclude = `To specify paths in a directory artifact that should always be excluded from the SHA256 calculation, you can add a ^.kosli_ignore^ file to the root of the artifact.
Each line should specify a relative path or path glob to be ignored. You can include comments in this file, using ^#^.
The ^.kosli_ignore^ file is always treated as part of the artifact: its own entries cannot exclude it, so the exclusion list cannot be changed without changing the fingerprint.
Paths the list already matches stay excluded whatever is later added there, so keep its entries as narrow as possible.`

// single source of truth for the env type lists shown in flag help texts;
// the server is the authority on which types are actually accepted
Expand Down
2 changes: 1 addition & 1 deletion cmd/kosli/snapshotAzureApps.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ from any other registry is read without credentials, which works for a public im
one; report those apps with ^--digests-source logs^ instead.

^--dry-run^ suppresses only the request to Kosli. Azure discovery and registry lookups still run.
` + kosliIgnoreDesc + azureAuthDesc
` + kosliIgnoreDescNoExclude + azureAuthDesc

const snapshotAzureAppsExample = `
# Use Azure Container Registry to get the digests for artifacts in a snapshot
Expand Down
2 changes: 1 addition & 1 deletion cmd/kosli/snapshotS3.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const snapshotS3LongDesc = snapshotS3ShortDesc + awsAuthDesc + `
You can report the entire bucket content, or filter some of the content using ^--include^ / ^--exclude^ (literal prefix match) or ^--include-regex^ / ^--exclude-regex^ (Go regular expressions matched against the full object key).
In all cases, the content is reported as one artifact. If you wish to report separate files/dirs within the same bucket as separate artifacts, you need to run the command twice.

` + kosliIgnoreDesc
` + kosliIgnoreDescNoExclude

const snapshotS3Example = `
# report the contents of an entire AWS S3 bucket (AWS auth provided in env variables):
Expand Down
5 changes: 4 additions & 1 deletion cmd/kosli/testdata/output/docs/mintlify/artifact.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ is set), registry credentials are resolved as follows:

To specify paths in a directory artifact that should always be excluded from the SHA256 calculation, you can add a `.kosli_ignore` file to the root of the artifact.
Each line should specify a relative path or path glob to be ignored. You can include comments in this file, using `#`.
The `.kosli_ignore` will be treated as part of the artifact like any other file, unless it is explicitly ignored itself.
The `.kosli_ignore` file is always treated as part of the artifact: its own entries cannot exclude it, so the exclusion list cannot be changed without changing the fingerprint.
Paths the list already matches stay excluded whatever is later added there, so keep its entries as narrow as possible.
Excluding the file with `--exclude` keeps it out of the fingerprint but still applies the paths it lists, which lets a writable directory change the list again.
To drop the file from the fingerprint safely, move its entries to `--exclude` and delete it.

## Flags
| Flag | Type | Description |
Expand Down
5 changes: 4 additions & 1 deletion cmd/kosli/testdata/output/docs/mintlify/snyk.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ The attestation can be bound to an *artifact* in two ways:

To specify paths in a directory artifact that should always be excluded from the SHA256 calculation, you can add a `.kosli_ignore` file to the root of the artifact.
Each line should specify a relative path or path glob to be ignored. You can include comments in this file, using `#`.
The `.kosli_ignore` will be treated as part of the artifact like any other file, unless it is explicitly ignored itself.
The `.kosli_ignore` file is always treated as part of the artifact: its own entries cannot exclude it, so the exclusion list cannot be changed without changing the fingerprint.
Paths the list already matches stay excluded whatever is later added there, so keep its entries as narrow as possible.
Excluding the file with `--exclude` keeps it out of the fingerprint but still applies the paths it lists, which lets a writable directory change the list again.
To drop the file from the fingerprint safely, move its entries to `--exclude` and delete it.

You can optionally associate the attestation to a git commit using `--commit` (requires access to a git repo).
You can optionally redact some of the git commit data sent to Kosli using `--redact-commit-info`.
Expand Down
118 changes: 107 additions & 11 deletions internal/digest/digest.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ var (
"has it been pushed to or pulled from a registry?")
)

// ignoreFileName is the exclusion list a directory artifact may carry at its root.
const ignoreFileName = ".kosli_ignore"

// DirSha256 returns sha256 digest of a directory
func DirSha256(dirPath string, excludePaths []string, logger *logger.Logger) (string, error) {
logger.Debug("calculating fingerprint for path [%s] -- excluding paths: %s", dirPath, excludePaths)
Expand Down Expand Up @@ -66,16 +69,48 @@ func DirSha256(dirPath string, excludePaths []string, logger *logger.Logger) (st
logger.Warn("failed to close digests file: %v", err)
}
}()
ignoreFilePath := filepath.Join(dirPath, ".kosli_ignore")
ignoredPaths, err := excludePathsFromFile(ignoreFilePath)
// An exclusion list cannot exclude itself, or a tree could add files and keep the
// approved fingerprint by listing them.
ignoreFileInTree, err := ignoreFilePathInTree(dirPath)
if err != nil {
return "", err
}
protectedPath := ignoreFileInTree

pathsToExclude, err := resolveExcludePaths(dirPath, excludePaths)
if err != nil {
return "", err
}

// A flag exclusion drops the list's content from the digest, so the tree may
// supply any content on a later run whatever it holds now.
if ignoreFileInTree != "" && utils.Contains(pathsToExclude, ignoreFileInTree) {
protectedPath = ""
logger.Warn("%s is excluded by a flag, so its rules are applied while its content is not fingerprinted: "+
"the directory can change the list at any time, and files it comes to list stay invisible. "+
"Move the entries to --exclude and delete the file to get the same fingerprint without that.", ignoreFileInTree)
Comment thread
pbeckham marked this conversation as resolved.
}
Comment thread
pbeckham marked this conversation as resolved.

// Reading the located path makes the file protected, the file read and the file
// hashed one string. The rules are read before the file is hashed, so a tree that
// rewrites it in between has these rules applied to different content.
ignoredPaths := []string{}
if ignoreFileInTree != "" {
ignoredPaths, err = excludePathsFromFile(ignoreFileInTree)
if err != nil {
return "", err
}
}
if len(ignoredPaths) > 0 {
logger.Debug(" -> ignore file used %s -- excluding paths: %s", ignoreFilePath, ignoredPaths)
logger.Debug(" -> ignore file used %s -- excluding paths: %s", ignoreFileInTree, ignoredPaths)
}
Comment thread
pbeckham marked this conversation as resolved.
excludePaths = append(excludePaths, ignoredPaths...)
err = calculateDirContentSha256(digestsFile, dirPath, tmpDir, excludePaths, logger)
resolvedIgnoredPaths, err := resolveExcludePaths(dirPath, ignoredPaths)
if err != nil {
return "", err
}
pathsToExclude = append(pathsToExclude, resolvedIgnoredPaths...)

err = calculateDirContentSha256(digestsFile, dirPath, tmpDir, pathsToExclude, protectedPath, logger)
if err != nil {
return "", err
}
Expand Down Expand Up @@ -193,17 +228,69 @@ func Sha256Fingerprint(parsed godigest.Digest) (string, error) {
return parsed.Encoded(), nil
}

// calculateDirContentSha256 calculates a sha256 digest for a directory content
func calculateDirContentSha256(digestsFile *os.File, dirPath, tmpDir string, excludePaths []string, logger *logger.Logger) error {
// ignoreFilePathInTree returns the tree's ignore file as filepath.WalkDir emits
// it, or "" when there is none.
//
// The name comes from the directory listing because a case-insensitive filesystem
// stores one spelling and opens any of them. Snapshotting S3 or Azure unzips the
// tree onto the machine running the CLI, so that filesystem is the operator's.
//
// An exact match wins over a folded one so that ignoreFileName owns the rules
// where a case-sensitive filesystem holds both spellings as distinct files.
func ignoreFilePathInTree(dirPath string) (string, error) {
// "" is also the answer for a tree with no ignore file, so a swallowed error
// would silently mean "no exclusions".
if _, err := os.Lstat(filepath.Join(dirPath, ignoreFileName)); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return "", nil
Comment thread
pbeckham marked this conversation as resolved.
}
return "", err
}
entries, err := os.ReadDir(dirPath)
Comment thread
pbeckham marked this conversation as resolved.
if err != nil {
return "", err
}
Comment thread
pbeckham marked this conversation as resolved.
folded := ""
for _, entry := range entries {
if !strings.EqualFold(entry.Name(), ignoreFileName) {
continue
}
// Only a file can carry rules. The dirent type is not enough on its own: it
// reports a symlink to a directory as a link, and following that would fail
// the read where a plain directory of this name is skipped.
path := filepath.Join(dirPath, entry.Name())
if entry.IsDir() {
continue
}
Comment thread
pbeckham marked this conversation as resolved.
if resolved, err := os.Stat(path); err == nil && resolved.IsDir() {
continue
}
Comment thread
pbeckham marked this conversation as resolved.
Comment thread
pbeckham marked this conversation as resolved.
if entry.Name() == ignoreFileName {
return path, nil
}
if folded == "" {
folded = path
}
}
return folded, nil
Comment thread
pbeckham marked this conversation as resolved.
}

// resolveExcludePaths expands exclusion patterns, relative to dirPath, into the
// paths they actually match.
func resolveExcludePaths(dirPath string, excludePaths []string) ([]string, error) {
pathsToExclude := []string{}
for _, p := range excludePaths {
found, err := filepathx.Glob(filepath.Join(dirPath, p))
if err != nil {
return err
return nil, err
}
pathsToExclude = append(pathsToExclude, found...)
Comment thread
pbeckham marked this conversation as resolved.
}
return pathsToExclude, nil
}

// calculateDirContentSha256 calculates a sha256 digest for a directory content
func calculateDirContentSha256(digestsFile *os.File, dirPath, tmpDir string, pathsToExclude []string, protectedPath string, logger *logger.Logger) error {
return filepath.WalkDir(dirPath, func(path string, info fs.DirEntry, err error) error {
if err != nil {
return err
Expand All @@ -216,12 +303,16 @@ func calculateDirContentSha256(digestsFile *os.File, dirPath, tmpDir string, exc
}

if utils.Contains(pathsToExclude, path) {
if info.IsDir() {
if path == protectedPath {
logger.Debug("keeping %s although an exclusion matches it: an exclusion list cannot exclude itself. "+
"Move its entries to --exclude and delete the file to recover the previous fingerprint.", path)
} else if info.IsDir() {
logger.Debug("skipping dir %s (and its contents) as it matches excluded paths", path)
return fs.SkipDir
} else {
logger.Debug("skipping %s as it matches excluded paths", path)
return nil
}
logger.Debug("skipping %s as it matches excluded paths", path)
return nil
}

// If it's a symlink, resolve the target
Expand Down Expand Up @@ -453,6 +544,11 @@ func excludePathsFromFile(path string) ([]string, error) {
excludes = append(excludes, line)
}
}
// A stopped scan yields the entries read so far, so an unchecked error means
// fingerprinting against a rule set the file does not hold.
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read %s: %w", path, err)
}
return excludes, nil
} else if errors.Is(err, fs.ErrNotExist) {
return []string{}, nil
Expand Down
Loading
Loading