"""
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License,
or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Copyright © 2019 Cloud Linux Software Inc.
This software is also available under ImunifyAV commercial license,
see
Patchman filesystem scanner.
Performance characteristics:
Speed — each scanned file's content is hashed exactly once. Previously the file
was re-read and re-hashed once per ``all_possible_relative_paths`` level (up to
the path depth); the raw digest is now computed once per file in ``main`` and
shared with the hashes and patch-dependency matchers. Files are matched against
definitions via O(1) digest lookups rather than a linear scan of the signature
list.
Memory — peak RSS is independent of the number of files (and matches) scanned.
Traversal is a lazy ``os.walk`` generator, and match results are deduplicated
in the output DB (a UNIQUE constraint + ``INSERT OR IGNORE``) and written
through a fixed-size insert buffer rather than accumulated in RAM. Memory is
therefore bounded by the size of the loaded definition set, not by the
workload.
"""
import argparse
import logging
import os
from imav.patchman.fs_scanner.utils import HashCalculator
from imav.patchman.fs_scanner.db import DB
from imav.patchman.fs_scanner.matcher import (
PatchDependenciesMatcher,
VersionsMatcher,
HashesMatcher,
)
from imav.patchman.fs_scanner.utils import (
all_possible_relative_paths,
dir_traverse_generator,
)
logger = logging.getLogger(__name__)
class ReadableFileAction(argparse.Action):
"""
Check that file exists and is readable
"""
def __call__(self, parser, namespace, values, option_string=None):
if not os.path.isfile(values):
raise ValueError(f"{values} does not exist")
if not os.access(values, os.R_OK):
raise ValueError(f"{values} is not readable")
setattr(namespace, self.dest, values)
class WritableFilePathAction(argparse.Action):
"""
Check that file path is writable
"""
def __call__(self, parser, namespace, values, option_string=None):
dir_name = os.path.dirname(values) or "."
if not os.access(dir_name, os.W_OK):
raise ValueError(f"Directory {dir_name} is not writable")
setattr(namespace, self.dest, values)
def main():
argparser = argparse.ArgumentParser()
argparser.add_argument(
"version_identifiers",
type=str,
action=ReadableFileAction,
help="Path to file with version_identifiers",
)
argparser.add_argument(
"patch_deps",
type=str,
action=ReadableFileAction,
help="Path to file with patch_dependencies",
)
argparser.add_argument(
"hashes",
type=str,
action=ReadableFileAction,
help="Path to file with hashes",
)
argparser.add_argument(
"target_dir",
type=os.path.abspath,
help="Path to target dir",
)
argparser.add_argument(
"--output",
"-o",
type=str,
action=WritableFilePathAction,
help="Path to output sqlite db file",
required=True,
)
argparser.add_argument(
"--algorithm",
type=str,
choices=HashCalculator.HASHING_ALGORITHMS.keys(),
default="md5",
help="Hashing algorithm",
)
args = argparser.parse_args()
if not os.path.isdir(args.target_dir):
logger.info(
"Target scan directory %s does not exist or is not a "
"directory; skipping",
args.target_dir,
)
with DB(args.output):
pass
return
versions_matcher = VersionsMatcher(args.version_identifiers)
deps_matcher = PatchDependenciesMatcher(args.patch_deps)
hash_calculator = HashCalculator(args.algorithm)
hashes_matcher = HashesMatcher(args.hashes)
with DB(args.output) as db:
for file_path in dir_traverse_generator(args.target_dir):
# Hash each file exactly once, up front, and reuse the digest for
# the hashes and patch-dependency matchers (both match the file's
# own raw content hash). The version matcher hashes a normalized,
# multi-file set, so it keeps its own calculator.
file_hash = hash_calculator.calc_hash(file_path)
hashes_matched = hashes_matcher.match_and_save(
file_path, file_hash, db
)
for index, relative_path in enumerate(
all_possible_relative_paths(file_path, args.target_dir)
):
version_matched = versions_matcher.match_and_save(
file_path, relative_path, db, hash_calculator
)
deps_matched = deps_matcher.match_and_save(
file_path, relative_path, file_hash
)
# Preserve prior behaviour: the early-out only ever triggered
# on the first (longest) relative path, because the hashes
# matcher deduplicated repeats on subsequent paths. Once all
# three categories match there, shorter paths add nothing.
if (
index == 0
and version_matched
and deps_matched
and hashes_matched
):
break
# Patch-dependency membership spans multiple files, so dependencies_met
# (whole-set recognition) is decided only after the full traversal.
deps_matcher.finalize(db)
if __name__ == "__main__":
main()